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
164 router = router.post("/admin/login", |req, _params| async move {
168 handle_login(req).await
169 });
170 router = router.post("/admin/logout", |_req, _params| async move {
171 Ok::<Response, Error>(handle_logout())
172 });
173
174 for registrar in self.registrars {
175 router = registrar(router, db);
176 }
177 router
178 }
179}
180
181impl Default for Admin {
182 fn default() -> Self {
183 Self::new()
184 }
185}
186
187pub fn register<T>(router: Router, db: &Db) -> Router
190where
191 T: AdminModel + Model,
192{
193 Admin::new().model::<T>().register(router, db)
194}
195
196fn mount_model<T>(mut router: Router, db: &Db) -> Router
197where
198 T: AdminModel + Model,
199{
200 let base = format!("/admin/{}", T::ADMIN_NAME);
201 let create_path = format!("{base}/create");
202 let edit_path = format!("{base}/:id/edit");
203 let delete_path = format!("{base}/:id/delete");
204
205 let list_db = db.clone();
206 router = router.get(&base, move |req, _params| {
207 let db = list_db.clone();
208 async move {
209 if let Err(resp) = admin_guard(req.ctx()) {
210 return Ok(resp);
211 }
212 let items = T::all(&db).await?;
213 Ok::<Response, Error>(html(admin_layout(T::DISPLAY_NAME, &list_page::<T>(&items))))
214 }
215 });
216
217 router = router.get(&create_path, |req, _params| async move {
218 if let Err(resp) = admin_guard(req.ctx()) {
219 return Ok(resp);
220 }
221 Ok::<Response, Error>(html(admin_layout(
222 &format!("New {}", T::DISPLAY_NAME),
223 &form_page::<T>(None, &format!("/admin/{}/create", T::ADMIN_NAME)),
224 )))
225 });
226
227 let create_db = db.clone();
228 router = router.post(&create_path, move |req, _params| {
229 let db = create_db.clone();
230 async move {
231 if let Err(resp) = admin_guard(req.ctx()) {
232 return Ok(resp);
233 }
234 let form = read_form(req).await?;
235 let item = T::from_form(&form, None)?;
236 item.create(&db).await?;
237 Ok::<Response, Error>(redirect(&format!("/admin/{}", T::ADMIN_NAME)))
238 }
239 });
240
241 let edit_db = db.clone();
242 router = router.get(&edit_path, move |req, params| {
243 let db = edit_db.clone();
244 async move {
245 if let Err(resp) = admin_guard(req.ctx()) {
246 return Ok(resp);
247 }
248 let id = parse_id_param(¶ms)?;
249 let item = T::find(&db, id).await?.ok_or(Error::NotFound)?;
250 Ok::<Response, Error>(html(admin_layout(
251 &format!("Edit {}", T::DISPLAY_NAME),
252 &form_page::<T>(
253 Some(&item),
254 &format!("/admin/{}/{}/edit", T::ADMIN_NAME, id),
255 ),
256 )))
257 }
258 });
259
260 let update_db = db.clone();
261 router = router.post(&edit_path, move |req, params| {
262 let db = update_db.clone();
263 async move {
264 if let Err(resp) = admin_guard(req.ctx()) {
265 return Ok(resp);
266 }
267 let id = parse_id_param(¶ms)?;
268 let form = read_form(req).await?;
269 let item = T::from_form(&form, Some(id))?;
270 item.update(&db).await?;
271 Ok::<Response, Error>(redirect(&format!("/admin/{}", T::ADMIN_NAME)))
272 }
273 });
274
275 let delete_db = db.clone();
276 router = router.post(&delete_path, move |req, params| {
277 let db = delete_db.clone();
278 async move {
279 if let Err(resp) = admin_guard(req.ctx()) {
280 return Ok(resp);
281 }
282 let id = parse_id_param(¶ms)?;
283 T::delete(&db, id).await?;
284 Ok::<Response, Error>(redirect(&format!("/admin/{}", T::ADMIN_NAME)))
285 }
286 });
287
288 router
289}
290
291fn parse_id_param(params: &crate::router::Params) -> Result<i64, Error> {
292 params
293 .get("id")
294 .and_then(|s| s.parse::<i64>().ok())
295 .ok_or_else(|| Error::BadRequest(String::from("invalid id")))
296}
297
298async fn read_form(req: Request) -> Result<FormData, Error> {
299 let (_, body, _) = req.into_parts();
300 let collected = body
301 .collect()
302 .await
303 .map_err(|e| Error::BadRequest(e.to_string()))?
304 .to_bytes();
305 let body_str = std::str::from_utf8(&collected).map_err(|e| Error::BadRequest(e.to_string()))?;
306 Ok(FormData::parse(body_str))
307}
308
309fn redirect(to: &str) -> Response {
310 hyper::Response::builder()
311 .status(303)
312 .header("location", to)
313 .body(Full::new(Bytes::new()))
314 .expect("valid redirect")
315}
316
317fn admin_layout(title: &str, content: &str) -> String {
318 format!(
319 r#"<!doctype html>
320<html lang="en">
321<head>
322<meta charset="utf-8">
323<meta name="viewport" content="width=device-width, initial-scale=1">
324<title>{title} — RustIO Admin</title>
325<style>{css}</style>
326</head>
327<body>
328<header>
329<h1><a href="/admin">RustIO Admin</a></h1>
330<form method="post" action="/admin/logout" class="header-logout">
331<button type="submit">Sign out</button>
332</form>
333</header>
334<main>{content}</main>
335</body>
336</html>"#,
337 title = escape_html(title),
338 css = ADMIN_CSS,
339 content = content,
340 )
341}
342
343fn index_page(entries: &[AdminEntry]) -> String {
344 if entries.is_empty() {
345 return String::from(
346 r#"<h2>Admin</h2>
347<p class="empty">No models are registered. Add one with
348<code>Admin::new().model::<YourModel>()</code> or scaffold an app
349via <code>rustio new app <name></code>.</p>"#,
350 );
351 }
352 let rows: String = entries
353 .iter()
354 .map(|e| {
355 format!(
356 r#"<li><a href="/admin/{name}"><span class="label">{display}</span><span class="path">/admin/{name}</span></a></li>"#,
357 name = escape_html(e.admin_name),
358 display = escape_html(e.display_name),
359 )
360 })
361 .collect();
362 format!(
363 r#"<h2>Admin</h2>
364<ul class="admin-index">{rows}</ul>"#
365 )
366}
367
368fn list_page<T: AdminModel>(items: &[T]) -> String {
369 let headers: String = T::FIELDS
370 .iter()
371 .map(|f| format!("<th>{}</th>", escape_html(f.name)))
372 .collect();
373 let rows: String = items
374 .iter()
375 .map(|item| {
376 let cells: String = T::FIELDS
377 .iter()
378 .map(|f| {
379 let v = item.field_display(f.name).unwrap_or_default();
380 format!("<td>{}</td>", escape_html(&v))
381 })
382 .collect();
383 let id = item.id();
384 let actions = format!(
385 r#"<td class="actions">
386<a href="/admin/{name}/{id}/edit">edit</a>
387<form method="post" action="/admin/{name}/{id}/delete">
388<button type="submit" class="danger">delete</button>
389</form>
390</td>"#,
391 name = T::ADMIN_NAME,
392 id = id,
393 );
394 format!("<tr>{cells}{actions}</tr>")
395 })
396 .collect();
397
398 format!(
399 r#"<div class="toolbar">
400<h2>{title}</h2>
401<a class="button" href="/admin/{name}/create">New {singular}</a>
402</div>
403<table>
404<thead><tr>{headers}<th>actions</th></tr></thead>
405<tbody>{rows}</tbody>
406</table>"#,
407 title = escape_html(T::DISPLAY_NAME),
408 singular = escape_html(T::singular_name()),
409 name = T::ADMIN_NAME,
410 )
411}
412
413fn form_page<T: AdminModel>(item: Option<&T>, action: &str) -> String {
414 let fields: String = T::FIELDS
415 .iter()
416 .filter(|f| f.editable)
417 .map(|f| render_field::<T>(f, item))
418 .collect();
419 let heading = if item.is_some() {
420 format!("Edit {}", T::singular_name())
421 } else {
422 format!("New {}", T::singular_name())
423 };
424 format!(
425 r#"<h2>{heading}</h2>
426<form method="post" action="{action}">
427{fields}
428<div class="form-actions">
429<button type="submit">Save</button>
430<a class="cancel" href="/admin/{name}">Cancel</a>
431</div>
432</form>"#,
433 heading = escape_html(&heading),
434 action = escape_html(action),
435 name = T::ADMIN_NAME,
436 )
437}
438
439fn render_field<T: AdminModel>(f: &AdminField, item: Option<&T>) -> String {
440 let current = item
441 .and_then(|i| i.field_display(f.name))
442 .unwrap_or_default();
443 let input = match f.ty {
444 FieldType::Bool => format!(
445 r#"<input type="checkbox" name="{n}" {checked}>"#,
446 n = escape_html(f.name),
447 checked = if current == "true" { "checked" } else { "" },
448 ),
449 FieldType::I32 | FieldType::I64 => format!(
450 r#"<input type="number" name="{n}" value="{v}">"#,
451 n = escape_html(f.name),
452 v = escape_html(¤t),
453 ),
454 FieldType::String => format!(
455 r#"<input type="text" name="{n}" value="{v}">"#,
456 n = escape_html(f.name),
457 v = escape_html(¤t),
458 ),
459 };
460 format!(
461 r#"<label><span>{label}</span>{input}</label>"#,
462 label = escape_html(f.name),
463 input = input,
464 )
465}
466
467#[allow(clippy::result_large_err)]
479fn admin_guard(ctx: &crate::context::Context) -> Result<(), Response> {
480 match crate::auth::require_admin(ctx) {
485 Ok(_) => Ok(()),
486 Err(Error::Unauthorized) => Err(login_page(401, None)),
487 Err(Error::Forbidden) => Err(forbidden_page()),
488 Err(other) => Err(other.into_response()),
489 }
490}
491
492fn login_page(status: u16, error: Option<&str>) -> Response {
495 let error_html = match error {
496 Some(msg) => format!(r#"<p class="error">{}</p>"#, escape_html(msg)),
497 None => String::new(),
498 };
499
500 let hint = if crate::auth::in_production() {
504 String::new()
505 } else {
506 String::from(
507 r#"<p class="hint">Development tokens: <code>dev-admin</code> (full access) · <code>dev-user</code> (non-admin, to preview 403).</p>"#,
508 )
509 };
510
511 let body = format!(
512 r#"<!doctype html>
513<html lang="en">
514<head>
515<meta charset="utf-8">
516<meta name="viewport" content="width=device-width, initial-scale=1">
517<title>Sign in — RustIO Admin</title>
518<style>{css}</style>
519</head>
520<body>
521<header><h1><a href="/admin">RustIO Admin</a></h1></header>
522<main class="auth-card">
523<h2>Sign in</h2>
524<form method="post" action="/admin/login" autocomplete="off">
525<label><span>Token</span>
526<input type="password" name="token" autofocus required>
527</label>
528{error}
529<button type="submit">Sign in</button>
530</form>
531{hint}
532</main>
533</body>
534</html>"#,
535 css = ADMIN_CSS,
536 error = error_html,
537 hint = hint,
538 );
539
540 hyper::Response::builder()
541 .status(status)
542 .header("content-type", "text/html; charset=utf-8")
543 .body(Full::new(Bytes::from(body)))
544 .expect("valid response")
545}
546
547fn forbidden_page() -> Response {
548 let body = format!(
549 r#"<!doctype html>
550<html lang="en">
551<head>
552<meta charset="utf-8">
553<meta name="viewport" content="width=device-width, initial-scale=1">
554<title>403 Forbidden — RustIO Admin</title>
555<style>{css}</style>
556</head>
557<body>
558<header><h1><a href="/admin">RustIO Admin</a></h1></header>
559<main class="auth-error">
560<div class="status">403</div>
561<p class="heading">Forbidden</p>
562<p class="hint">You are signed in but your account isn't an admin.</p>
563<form method="post" action="/admin/logout" class="inline">
564<button type="submit" class="secondary">Sign out</button>
565</form>
566</main>
567</body>
568</html>"#,
569 css = ADMIN_CSS,
570 );
571
572 hyper::Response::builder()
573 .status(403)
574 .header("content-type", "text/html; charset=utf-8")
575 .body(Full::new(Bytes::from(body)))
576 .expect("valid response")
577}
578
579async fn handle_login(req: Request) -> Result<Response, Error> {
584 let form = read_form(req).await?;
585 let token = form.get("token").unwrap_or("").trim().to_string();
586
587 if token.is_empty() {
588 return Ok(login_page(400, Some("Token is required.")));
589 }
590
591 if crate::auth::in_production() {
596 return Ok(login_page(
597 401,
598 Some("Sign-in is disabled in production until a real auth middleware is installed."),
599 ));
600 }
601
602 if crate::auth::dev_identity(&token).is_none() {
603 return Ok(login_page(401, Some("That token is not recognised.")));
604 }
605
606 let mut resp = redirect("/admin");
610 crate::http::set_cookie(
611 &mut resp,
612 &format!("rustio_token={token}; Path=/; HttpOnly; SameSite=Strict"),
613 );
614 Ok(resp)
615}
616
617fn handle_logout() -> Response {
620 let mut resp = redirect("/admin");
621 crate::http::set_cookie(
622 &mut resp,
623 "rustio_token=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0",
624 );
625 resp
626}
627
628fn escape_html(s: &str) -> String {
629 let mut out = String::with_capacity(s.len());
630 for ch in s.chars() {
631 match ch {
632 '&' => out.push_str("&"),
633 '<' => out.push_str("<"),
634 '>' => out.push_str(">"),
635 '"' => out.push_str("""),
636 '\'' => out.push_str("'"),
637 c => out.push(c),
638 }
639 }
640 out
641}
642
643const ADMIN_CSS: &str = r#"
644*, *::before, *::after { box-sizing: border-box; }
645body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
646 background: #fafafa; color: #222; margin: 0; }
647header { background: #222; color: white; padding: 1rem 2rem; display: flex; align-items: center; justify-content: space-between; }
648header h1 { margin: 0; font-size: 1.1rem; font-weight: 600; letter-spacing: 0.02em; }
649header h1 a { color: inherit; text-decoration: none; }
650header h1 a:hover { opacity: 0.9; }
651header .header-logout { margin: 0; }
652header .header-logout button { background: transparent; color: #d8d8dc; border: 1px solid #444; padding: 0.35rem 0.75rem; font-size: 0.85rem; border-radius: 4px; cursor: pointer; }
653header .header-logout button:hover { background: #2f2f33; color: white; }
654ul.admin-index { list-style: none; padding: 0; margin: 0; display: grid; gap: 0.5rem; }
655ul.admin-index li { background: white; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
656ul.admin-index li a { display: flex; justify-content: space-between; align-items: center; padding: 0.9rem 1.1rem; text-decoration: none; color: #222; }
657ul.admin-index li a:hover { background: #f4f4f5; }
658ul.admin-index li .label { font-weight: 600; }
659ul.admin-index li .path { color: #888; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.85rem; }
660p.empty { color: #666; }
661p.empty code { background: #f0f0f2; padding: 0.1rem 0.35rem; border-radius: 3px; font-size: 0.9em; }
662.auth-error { text-align: center; padding: 3rem 2rem; max-width: 36rem; margin: 0 auto; }
663.auth-error .status { font-size: 3rem; font-weight: 700; color: #b42318; line-height: 1; }
664.auth-error .heading { font-size: 1.15rem; margin: 0.5rem 0 1.5rem; font-weight: 600; color: #222; }
665.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; }
666.auth-error .hint code { background: #fdefe0; color: #7c2d12; padding: 0.1rem 0.35rem; border-radius: 3px; font-size: 0.9em; display: inline-block; }
667.auth-error form.inline { margin-top: 1rem; }
668.auth-error form.inline button.secondary { background: transparent; border: 1px solid #d0d0d4; color: #222; }
669.auth-card { max-width: 22rem; margin: 2.5rem auto; padding: 2rem; background: white; border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.05); }
670.auth-card h2 { margin: 0 0 1.25rem; font-size: 1.25rem; }
671.auth-card label { display: block; margin: 0 0 0.9rem; }
672.auth-card label span { display: block; font-weight: 500; margin-bottom: 0.25rem; font-size: 0.9rem; }
673.auth-card input[type=password] { width: 100%; padding: 0.55rem 0.75rem; border: 1px solid #d0d0d4; border-radius: 4px; font: inherit; }
674.auth-card button[type=submit] { width: 100%; padding: 0.6rem 1rem; background: #222; color: white; border: none; border-radius: 4px; font: inherit; cursor: pointer; }
675.auth-card button[type=submit]:hover { background: #000; }
676.auth-card .error { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; padding: 0.6rem 0.8rem; border-radius: 4px; margin: 0 0 0.9rem; font-size: 0.9rem; }
677.auth-card .hint { color: #666; font-size: 0.85rem; margin: 1rem 0 0; line-height: 1.5; }
678.auth-card .hint code { background: #f0f0f2; padding: 0.1rem 0.3rem; border-radius: 3px; font-size: 0.85em; }
679main { padding: 2rem; max-width: 60rem; margin: 0 auto; }
680h2 { margin: 0; }
681.toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; }
682table { border-collapse: collapse; width: 100%; background: white; border-radius: 6px; overflow: hidden;
683 box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
684th, td { text-align: left; padding: 0.6rem 0.9rem; border-bottom: 1px solid #eee; font-size: 0.95rem; }
685th { background: #f4f4f5; font-weight: 600; }
686tbody tr:last-child td { border-bottom: none; }
687td.actions { display: flex; gap: 0.5rem; align-items: center; }
688td.actions form { margin: 0; display: inline; }
689a { color: #0366d6; text-decoration: none; }
690a:hover { text-decoration: underline; }
691label { display: block; margin-bottom: 1rem; }
692label span { display: block; font-weight: 500; margin-bottom: 0.25rem; font-size: 0.9rem; }
693input[type=text], input[type=number] { padding: 0.5rem 0.75rem; border: 1px solid #d0d0d4;
694 border-radius: 4px; width: 24rem; max-width: 100%; font: inherit; }
695input[type=checkbox] { transform: scale(1.1); }
696button, .button { padding: 0.5rem 1rem; background: #222; color: white; border: none;
697 border-radius: 4px; cursor: pointer; font: inherit; text-decoration: none; display: inline-block; }
698button:hover, .button:hover { background: #000; text-decoration: none; }
699button.danger { background: #b42318; }
700button.danger:hover { background: #8a1c12; }
701.form-actions { display: flex; gap: 0.5rem; align-items: center; margin-top: 1rem; }
702.form-actions .cancel { color: #666; }
703"#;
704
705#[cfg(test)]
706mod tests {
707 use super::*;
708
709 #[test]
710 fn escape_html_escapes_dangerous_chars() {
711 assert_eq!(
712 escape_html("<script>alert(\"xss\")</script>"),
713 "<script>alert("xss")</script>"
714 );
715 assert_eq!(escape_html("a & b"), "a & b");
716 assert_eq!(escape_html("it's"), "it's");
717 }
718}