1use 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
20pub 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 fn singular_name() -> &'static str {
52 Self::DISPLAY_NAME
53 }
54}
55
56#[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
66pub 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 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 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 pub fn entries(&self) -> &[AdminEntry] {
145 &self.entries
146 }
147
148 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
176pub 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(¶ms)?;
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(¶ms)?;
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(¶ms)?;
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::<YourModel>()</code> or scaffold an app
333via <code>rustio new app <name></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(¤t),
437 ),
438 FieldType::String => format!(
439 r#"<input type="text" name="{n}" value="{v}">"#,
440 n = escape_html(f.name),
441 v = escape_html(¤t),
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#[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 Err(other) => Err(other.into_response()),
471 }
472}
473
474fn auth_error_html(status: u16, title: &str) -> Response {
475 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("&"),
527 '<' => out.push_str("<"),
528 '>' => out.push_str(">"),
529 '"' => out.push_str("""),
530 '\'' => out.push_str("'"),
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 "<script>alert("xss")</script>"
593 );
594 assert_eq!(escape_html("a & b"), "a & b");
595 assert_eq!(escape_html("it's"), "it's");
596 }
597}