Skip to main content

rustio_core/
admin.rs

1//! Auto-generated CRUD admin backed by [`crate::orm`].
2//!
3//! Build an [`Admin`] by chaining `.model::<T>()` calls, then mount it with
4//! [`Admin::register`]. This attaches list / create / edit / delete routes
5//! at `/admin/<admin_name>` for each model and an index page at `/admin`
6//! listing every registered model.
7//!
8//! For a single-model app, [`register`] is a convenience wrapper.
9
10use std::sync::Arc;
11
12use bytes::Bytes;
13use http_body_util::{BodyExt, Full};
14
15use crate::auth::require_admin;
16use crate::error::Error;
17use crate::http::{html, Request, Response};
18use crate::orm::{Db, Model};
19use crate::router::Router;
20
21// `FormData` lives in `http` and is re-exported here so that the
22// `#[derive(RustioAdmin)]`-generated code referencing
23// `::rustio_core::admin::FormData` continues to work.
24pub use crate::http::FormData;
25
26#[derive(Debug, Clone, Copy)]
27pub enum FieldType {
28    I32,
29    I64,
30    String,
31    Bool,
32}
33
34#[derive(Debug, Clone, Copy)]
35pub struct AdminField {
36    pub name: &'static str,
37    pub ty: FieldType,
38    pub editable: bool,
39}
40
41pub trait AdminModel: Model {
42    const ADMIN_NAME: &'static str;
43    const DISPLAY_NAME: &'static str;
44    const FIELDS: &'static [AdminField];
45
46    fn field_display(&self, name: &str) -> Option<String>;
47    fn from_form(form: &FormData, id: Option<i64>) -> Result<Self, Error>;
48
49    /// Singular form of the display name. Used for labels like "New X" and
50    /// "Edit X". Defaults to [`DISPLAY_NAME`]; the `#[derive(RustioAdmin)]`
51    /// macro generates a proper singular form.
52    fn singular_name() -> &'static str {
53        Self::DISPLAY_NAME
54    }
55}
56
57/// Metadata about one registered admin model.
58#[derive(Debug, Clone)]
59pub struct AdminEntry {
60    pub admin_name: &'static str,
61    pub display_name: &'static str,
62    pub singular_name: &'static str,
63}
64
65type ModelRegistrar = Box<dyn FnOnce(Router, &Db) -> Router + Send + Sync>;
66
67/// Builder that collects admin models and mounts them with a shared
68/// `/admin` index page.
69///
70/// ```no_run
71/// use rustio_core::admin::Admin;
72/// # use rustio_core::{Db, Router};
73/// # fn demo(router: Router, db: &Db) -> Router {
74/// # struct Post; struct User;
75/// # impl rustio_core::Model for Post {
76/// #   const TABLE: &'static str = "posts";
77/// #   const COLUMNS: &'static [&'static str] = &[];
78/// #   const INSERT_COLUMNS: &'static [&'static str] = &[];
79/// #   fn id(&self) -> i64 { 0 }
80/// #   fn from_row(_: rustio_core::Row<'_>) -> Result<Self, rustio_core::Error> { unimplemented!() }
81/// #   fn insert_values(&self) -> Vec<rustio_core::Value> { vec![] }
82/// # }
83/// # impl rustio_core::Model for User {
84/// #   const TABLE: &'static str = "users";
85/// #   const COLUMNS: &'static [&'static str] = &[];
86/// #   const INSERT_COLUMNS: &'static [&'static str] = &[];
87/// #   fn id(&self) -> i64 { 0 }
88/// #   fn from_row(_: rustio_core::Row<'_>) -> Result<Self, rustio_core::Error> { unimplemented!() }
89/// #   fn insert_values(&self) -> Vec<rustio_core::Value> { vec![] }
90/// # }
91/// # impl rustio_core::admin::AdminModel for Post {
92/// #   const ADMIN_NAME: &'static str = "posts"; const DISPLAY_NAME: &'static str = "Posts";
93/// #   const FIELDS: &'static [rustio_core::admin::AdminField] = &[];
94/// #   fn field_display(&self, _: &str) -> Option<String> { None }
95/// #   fn from_form(_: &rustio_core::admin::FormData, _: Option<i64>) -> Result<Self, rustio_core::Error> { unimplemented!() }
96/// # }
97/// # impl rustio_core::admin::AdminModel for User {
98/// #   const ADMIN_NAME: &'static str = "users"; const DISPLAY_NAME: &'static str = "Users";
99/// #   const FIELDS: &'static [rustio_core::admin::AdminField] = &[];
100/// #   fn field_display(&self, _: &str) -> Option<String> { None }
101/// #   fn from_form(_: &rustio_core::admin::FormData, _: Option<i64>) -> Result<Self, rustio_core::Error> { unimplemented!() }
102/// # }
103/// Admin::new()
104///     .model::<Post>()
105///     .model::<User>()
106///     .register(router, db)
107/// # }
108/// ```
109pub struct Admin {
110    entries: Vec<AdminEntry>,
111    registrars: Vec<ModelRegistrar>,
112}
113
114impl Admin {
115    pub fn new() -> Self {
116        Self {
117            entries: Vec::new(),
118            registrars: Vec::new(),
119        }
120    }
121
122    /// Register a model on this admin. Adds its metadata to the index
123    /// and queues its CRUD routes for mounting.
124    pub fn model<T: AdminModel>(mut self) -> Self {
125        self.entries.push(AdminEntry {
126            admin_name: T::ADMIN_NAME,
127            display_name: T::DISPLAY_NAME,
128            singular_name: T::singular_name(),
129        });
130        self.registrars
131            .push(Box::new(|router, db| mount_model::<T>(router, db)));
132        self
133    }
134
135    /// Number of registered models.
136    pub fn len(&self) -> usize {
137        self.entries.len()
138    }
139
140    pub fn is_empty(&self) -> bool {
141        self.entries.is_empty()
142    }
143
144    /// Metadata for inspection.
145    pub fn entries(&self) -> &[AdminEntry] {
146        &self.entries
147    }
148
149    /// Mount the admin onto a router: installs `/admin` (index) and
150    /// CRUD routes for every registered model. Admin-only; handlers
151    /// return 401/403 via [`require_admin`].
152    pub fn register(self, mut router: Router, db: &Db) -> Router {
153        let entries = Arc::new(self.entries);
154        let index_entries = entries.clone();
155        router = router.get("/admin", move |req, _params| {
156            let entries = index_entries.clone();
157            async move {
158                require_admin(req.ctx())?;
159                Ok::<Response, Error>(html(admin_layout("Admin", &index_page(&entries))))
160            }
161        });
162        for registrar in self.registrars {
163            router = registrar(router, db);
164        }
165        router
166    }
167}
168
169impl Default for Admin {
170    fn default() -> Self {
171        Self::new()
172    }
173}
174
175/// Convenience: mount CRUD routes and an `/admin` index for a single model.
176/// Equivalent to `Admin::new().model::<T>().register(router, db)`.
177pub fn register<T>(router: Router, db: &Db) -> Router
178where
179    T: AdminModel + Model,
180{
181    Admin::new().model::<T>().register(router, db)
182}
183
184fn mount_model<T>(mut router: Router, db: &Db) -> Router
185where
186    T: AdminModel + Model,
187{
188    let base = format!("/admin/{}", T::ADMIN_NAME);
189    let create_path = format!("{base}/create");
190    let edit_path = format!("{base}/:id/edit");
191    let delete_path = format!("{base}/:id/delete");
192
193    let list_db = db.clone();
194    router = router.get(&base, move |req, _params| {
195        let db = list_db.clone();
196        async move {
197            require_admin(req.ctx())?;
198            let items = T::all(&db).await?;
199            Ok::<Response, Error>(html(admin_layout(T::DISPLAY_NAME, &list_page::<T>(&items))))
200        }
201    });
202
203    router = router.get(&create_path, |req, _params| async move {
204        require_admin(req.ctx())?;
205        Ok::<Response, Error>(html(admin_layout(
206            &format!("New {}", T::DISPLAY_NAME),
207            &form_page::<T>(None, &format!("/admin/{}/create", T::ADMIN_NAME)),
208        )))
209    });
210
211    let create_db = db.clone();
212    router = router.post(&create_path, move |req, _params| {
213        let db = create_db.clone();
214        async move {
215            require_admin(req.ctx())?;
216            let form = read_form(req).await?;
217            let item = T::from_form(&form, None)?;
218            item.create(&db).await?;
219            Ok::<Response, Error>(redirect(&format!("/admin/{}", T::ADMIN_NAME)))
220        }
221    });
222
223    let edit_db = db.clone();
224    router = router.get(&edit_path, move |req, params| {
225        let db = edit_db.clone();
226        async move {
227            require_admin(req.ctx())?;
228            let id = parse_id_param(&params)?;
229            let item = T::find(&db, id).await?.ok_or(Error::NotFound)?;
230            Ok::<Response, Error>(html(admin_layout(
231                &format!("Edit {}", T::DISPLAY_NAME),
232                &form_page::<T>(
233                    Some(&item),
234                    &format!("/admin/{}/{}/edit", T::ADMIN_NAME, id),
235                ),
236            )))
237        }
238    });
239
240    let update_db = db.clone();
241    router = router.post(&edit_path, move |req, params| {
242        let db = update_db.clone();
243        async move {
244            require_admin(req.ctx())?;
245            let id = parse_id_param(&params)?;
246            let form = read_form(req).await?;
247            let item = T::from_form(&form, Some(id))?;
248            item.update(&db).await?;
249            Ok::<Response, Error>(redirect(&format!("/admin/{}", T::ADMIN_NAME)))
250        }
251    });
252
253    let delete_db = db.clone();
254    router = router.post(&delete_path, move |req, params| {
255        let db = delete_db.clone();
256        async move {
257            require_admin(req.ctx())?;
258            let id = parse_id_param(&params)?;
259            T::delete(&db, id).await?;
260            Ok::<Response, Error>(redirect(&format!("/admin/{}", T::ADMIN_NAME)))
261        }
262    });
263
264    router
265}
266
267fn parse_id_param(params: &crate::router::Params) -> Result<i64, Error> {
268    params
269        .get("id")
270        .and_then(|s| s.parse::<i64>().ok())
271        .ok_or_else(|| Error::BadRequest(String::from("invalid id")))
272}
273
274async fn read_form(req: Request) -> Result<FormData, Error> {
275    let (_, body, _) = req.into_parts();
276    let collected = body
277        .collect()
278        .await
279        .map_err(|e| Error::BadRequest(e.to_string()))?
280        .to_bytes();
281    let body_str = std::str::from_utf8(&collected).map_err(|e| Error::BadRequest(e.to_string()))?;
282    Ok(FormData::parse(body_str))
283}
284
285fn redirect(to: &str) -> Response {
286    hyper::Response::builder()
287        .status(303)
288        .header("location", to)
289        .body(Full::new(Bytes::new()))
290        .expect("valid redirect")
291}
292
293fn admin_layout(title: &str, content: &str) -> String {
294    format!(
295        r#"<!doctype html>
296<html lang="en">
297<head>
298<meta charset="utf-8">
299<meta name="viewport" content="width=device-width, initial-scale=1">
300<title>{title} — RustIO Admin</title>
301<style>{css}</style>
302</head>
303<body>
304<header><h1><a href="/admin">RustIO Admin</a></h1></header>
305<main>{content}</main>
306</body>
307</html>"#,
308        title = escape_html(title),
309        css = ADMIN_CSS,
310        content = content,
311    )
312}
313
314fn index_page(entries: &[AdminEntry]) -> String {
315    if entries.is_empty() {
316        return String::from(
317            r#"<h2>Admin</h2>
318<p class="empty">No models are registered. Add one with
319<code>Admin::new().model::&lt;YourModel&gt;()</code> or scaffold an app
320via <code>rustio new app &lt;name&gt;</code>.</p>"#,
321        );
322    }
323    let rows: String = entries
324        .iter()
325        .map(|e| {
326            format!(
327                r#"<li><a href="/admin/{name}"><span class="label">{display}</span><span class="path">/admin/{name}</span></a></li>"#,
328                name = escape_html(e.admin_name),
329                display = escape_html(e.display_name),
330            )
331        })
332        .collect();
333    format!(
334        r#"<h2>Admin</h2>
335<ul class="admin-index">{rows}</ul>"#
336    )
337}
338
339fn list_page<T: AdminModel>(items: &[T]) -> String {
340    let headers: String = T::FIELDS
341        .iter()
342        .map(|f| format!("<th>{}</th>", escape_html(f.name)))
343        .collect();
344    let rows: String = items
345        .iter()
346        .map(|item| {
347            let cells: String = T::FIELDS
348                .iter()
349                .map(|f| {
350                    let v = item.field_display(f.name).unwrap_or_default();
351                    format!("<td>{}</td>", escape_html(&v))
352                })
353                .collect();
354            let id = item.id();
355            let actions = format!(
356                r#"<td class="actions">
357<a href="/admin/{name}/{id}/edit">edit</a>
358<form method="post" action="/admin/{name}/{id}/delete">
359<button type="submit" class="danger">delete</button>
360</form>
361</td>"#,
362                name = T::ADMIN_NAME,
363                id = id,
364            );
365            format!("<tr>{cells}{actions}</tr>")
366        })
367        .collect();
368
369    format!(
370        r#"<div class="toolbar">
371<h2>{title}</h2>
372<a class="button" href="/admin/{name}/create">New {singular}</a>
373</div>
374<table>
375<thead><tr>{headers}<th>actions</th></tr></thead>
376<tbody>{rows}</tbody>
377</table>"#,
378        title = escape_html(T::DISPLAY_NAME),
379        singular = escape_html(T::singular_name()),
380        name = T::ADMIN_NAME,
381    )
382}
383
384fn form_page<T: AdminModel>(item: Option<&T>, action: &str) -> String {
385    let fields: String = T::FIELDS
386        .iter()
387        .filter(|f| f.editable)
388        .map(|f| render_field::<T>(f, item))
389        .collect();
390    let heading = if item.is_some() {
391        format!("Edit {}", T::singular_name())
392    } else {
393        format!("New {}", T::singular_name())
394    };
395    format!(
396        r#"<h2>{heading}</h2>
397<form method="post" action="{action}">
398{fields}
399<div class="form-actions">
400<button type="submit">Save</button>
401<a class="cancel" href="/admin/{name}">Cancel</a>
402</div>
403</form>"#,
404        heading = escape_html(&heading),
405        action = escape_html(action),
406        name = T::ADMIN_NAME,
407    )
408}
409
410fn render_field<T: AdminModel>(f: &AdminField, item: Option<&T>) -> String {
411    let current = item
412        .and_then(|i| i.field_display(f.name))
413        .unwrap_or_default();
414    let input = match f.ty {
415        FieldType::Bool => format!(
416            r#"<input type="checkbox" name="{n}" {checked}>"#,
417            n = escape_html(f.name),
418            checked = if current == "true" { "checked" } else { "" },
419        ),
420        FieldType::I32 | FieldType::I64 => format!(
421            r#"<input type="number" name="{n}" value="{v}">"#,
422            n = escape_html(f.name),
423            v = escape_html(&current),
424        ),
425        FieldType::String => format!(
426            r#"<input type="text" name="{n}" value="{v}">"#,
427            n = escape_html(f.name),
428            v = escape_html(&current),
429        ),
430    };
431    format!(
432        r#"<label><span>{label}</span>{input}</label>"#,
433        label = escape_html(f.name),
434        input = input,
435    )
436}
437
438fn escape_html(s: &str) -> String {
439    let mut out = String::with_capacity(s.len());
440    for ch in s.chars() {
441        match ch {
442            '&' => out.push_str("&amp;"),
443            '<' => out.push_str("&lt;"),
444            '>' => out.push_str("&gt;"),
445            '"' => out.push_str("&quot;"),
446            '\'' => out.push_str("&#39;"),
447            c => out.push(c),
448        }
449    }
450    out
451}
452
453const ADMIN_CSS: &str = r#"
454*, *::before, *::after { box-sizing: border-box; }
455body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
456  background: #fafafa; color: #222; margin: 0; }
457header { background: #222; color: white; padding: 1rem 2rem; }
458header h1 { margin: 0; font-size: 1.1rem; font-weight: 600; letter-spacing: 0.02em; }
459header h1 a { color: inherit; text-decoration: none; }
460header h1 a:hover { opacity: 0.9; }
461ul.admin-index { list-style: none; padding: 0; margin: 0; display: grid; gap: 0.5rem; }
462ul.admin-index li { background: white; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
463ul.admin-index li a { display: flex; justify-content: space-between; align-items: center; padding: 0.9rem 1.1rem; text-decoration: none; color: #222; }
464ul.admin-index li a:hover { background: #f4f4f5; }
465ul.admin-index li .label { font-weight: 600; }
466ul.admin-index li .path { color: #888; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.85rem; }
467p.empty { color: #666; }
468p.empty code { background: #f0f0f2; padding: 0.1rem 0.35rem; border-radius: 3px; font-size: 0.9em; }
469main { padding: 2rem; max-width: 60rem; margin: 0 auto; }
470h2 { margin: 0; }
471.toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; }
472table { border-collapse: collapse; width: 100%; background: white; border-radius: 6px; overflow: hidden;
473  box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
474th, td { text-align: left; padding: 0.6rem 0.9rem; border-bottom: 1px solid #eee; font-size: 0.95rem; }
475th { background: #f4f4f5; font-weight: 600; }
476tbody tr:last-child td { border-bottom: none; }
477td.actions { display: flex; gap: 0.5rem; align-items: center; }
478td.actions form { margin: 0; display: inline; }
479a { color: #0366d6; text-decoration: none; }
480a:hover { text-decoration: underline; }
481label { display: block; margin-bottom: 1rem; }
482label span { display: block; font-weight: 500; margin-bottom: 0.25rem; font-size: 0.9rem; }
483input[type=text], input[type=number] { padding: 0.5rem 0.75rem; border: 1px solid #d0d0d4;
484  border-radius: 4px; width: 24rem; max-width: 100%; font: inherit; }
485input[type=checkbox] { transform: scale(1.1); }
486button, .button { padding: 0.5rem 1rem; background: #222; color: white; border: none;
487  border-radius: 4px; cursor: pointer; font: inherit; text-decoration: none; display: inline-block; }
488button:hover, .button:hover { background: #000; text-decoration: none; }
489button.danger { background: #b42318; }
490button.danger:hover { background: #8a1c12; }
491.form-actions { display: flex; gap: 0.5rem; align-items: center; margin-top: 1rem; }
492.form-actions .cancel { color: #666; }
493"#;
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498
499    #[test]
500    fn escape_html_escapes_dangerous_chars() {
501        assert_eq!(
502            escape_html("<script>alert(\"xss\")</script>"),
503            "&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;"
504        );
505        assert_eq!(escape_html("a & b"), "a &amp; b");
506        assert_eq!(escape_html("it's"), "it&#39;s");
507    }
508}