1use 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
21pub 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 fn singular_name() -> &'static str {
53 Self::DISPLAY_NAME
54 }
55}
56
57#[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
67pub 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 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 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 pub fn entries(&self) -> &[AdminEntry] {
146 &self.entries
147 }
148
149 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
175pub 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(¶ms)?;
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(¶ms)?;
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(¶ms)?;
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::<YourModel>()</code> or scaffold an app
320via <code>rustio new app <name></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(¤t),
424 ),
425 FieldType::String => format!(
426 r#"<input type="text" name="{n}" value="{v}">"#,
427 n = escape_html(f.name),
428 v = escape_html(¤t),
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("&"),
443 '<' => out.push_str("<"),
444 '>' => out.push_str(">"),
445 '"' => out.push_str("""),
446 '\'' => out.push_str("'"),
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 "<script>alert("xss")</script>"
504 );
505 assert_eq!(escape_html("a & b"), "a & b");
506 assert_eq!(escape_html("it's"), "it's");
507 }
508}