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