use std::sync::Arc;
use bytes::Bytes;
use http_body_util::{BodyExt, Full};
use crate::error::Error;
use crate::http::{html, Request, Response};
use crate::orm::{Db, Model};
use crate::router::Router;
pub use crate::http::FormData;
#[derive(Debug, Clone, Copy)]
pub enum FieldType {
I32,
I64,
String,
Bool,
}
#[derive(Debug, Clone, Copy)]
pub struct AdminField {
pub name: &'static str,
pub ty: FieldType,
pub editable: bool,
}
pub trait AdminModel: Model {
const ADMIN_NAME: &'static str;
const DISPLAY_NAME: &'static str;
const FIELDS: &'static [AdminField];
fn field_display(&self, name: &str) -> Option<String>;
fn from_form(form: &FormData, id: Option<i64>) -> Result<Self, Error>;
fn singular_name() -> &'static str {
Self::DISPLAY_NAME
}
}
#[derive(Debug, Clone)]
pub struct AdminEntry {
pub admin_name: &'static str,
pub display_name: &'static str,
pub singular_name: &'static str,
}
type ModelRegistrar = Box<dyn FnOnce(Router, &Db) -> Router + Send + Sync>;
pub struct Admin {
entries: Vec<AdminEntry>,
registrars: Vec<ModelRegistrar>,
}
impl Admin {
pub fn new() -> Self {
Self {
entries: Vec::new(),
registrars: Vec::new(),
}
}
pub fn model<T: AdminModel>(mut self) -> Self {
self.entries.push(AdminEntry {
admin_name: T::ADMIN_NAME,
display_name: T::DISPLAY_NAME,
singular_name: T::singular_name(),
});
self.registrars
.push(Box::new(|router, db| mount_model::<T>(router, db)));
self
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn entries(&self) -> &[AdminEntry] {
&self.entries
}
pub fn register(self, mut router: Router, db: &Db) -> Router {
let entries = Arc::new(self.entries);
let index_entries = entries.clone();
router = router.get("/admin", move |req, _params| {
let entries = index_entries.clone();
async move {
if let Err(resp) = admin_guard(req.ctx()) {
return Ok(resp);
}
Ok::<Response, Error>(html(admin_layout("Admin", &index_page(&entries))))
}
});
for registrar in self.registrars {
router = registrar(router, db);
}
router
}
}
impl Default for Admin {
fn default() -> Self {
Self::new()
}
}
pub fn register<T>(router: Router, db: &Db) -> Router
where
T: AdminModel + Model,
{
Admin::new().model::<T>().register(router, db)
}
fn mount_model<T>(mut router: Router, db: &Db) -> Router
where
T: AdminModel + Model,
{
let base = format!("/admin/{}", T::ADMIN_NAME);
let create_path = format!("{base}/create");
let edit_path = format!("{base}/:id/edit");
let delete_path = format!("{base}/:id/delete");
let list_db = db.clone();
router = router.get(&base, move |req, _params| {
let db = list_db.clone();
async move {
if let Err(resp) = admin_guard(req.ctx()) {
return Ok(resp);
}
let items = T::all(&db).await?;
Ok::<Response, Error>(html(admin_layout(T::DISPLAY_NAME, &list_page::<T>(&items))))
}
});
router = router.get(&create_path, |req, _params| async move {
if let Err(resp) = admin_guard(req.ctx()) {
return Ok(resp);
}
Ok::<Response, Error>(html(admin_layout(
&format!("New {}", T::DISPLAY_NAME),
&form_page::<T>(None, &format!("/admin/{}/create", T::ADMIN_NAME)),
)))
});
let create_db = db.clone();
router = router.post(&create_path, move |req, _params| {
let db = create_db.clone();
async move {
if let Err(resp) = admin_guard(req.ctx()) {
return Ok(resp);
}
let form = read_form(req).await?;
let item = T::from_form(&form, None)?;
item.create(&db).await?;
Ok::<Response, Error>(redirect(&format!("/admin/{}", T::ADMIN_NAME)))
}
});
let edit_db = db.clone();
router = router.get(&edit_path, move |req, params| {
let db = edit_db.clone();
async move {
if let Err(resp) = admin_guard(req.ctx()) {
return Ok(resp);
}
let id = parse_id_param(¶ms)?;
let item = T::find(&db, id).await?.ok_or(Error::NotFound)?;
Ok::<Response, Error>(html(admin_layout(
&format!("Edit {}", T::DISPLAY_NAME),
&form_page::<T>(
Some(&item),
&format!("/admin/{}/{}/edit", T::ADMIN_NAME, id),
),
)))
}
});
let update_db = db.clone();
router = router.post(&edit_path, move |req, params| {
let db = update_db.clone();
async move {
if let Err(resp) = admin_guard(req.ctx()) {
return Ok(resp);
}
let id = parse_id_param(¶ms)?;
let form = read_form(req).await?;
let item = T::from_form(&form, Some(id))?;
item.update(&db).await?;
Ok::<Response, Error>(redirect(&format!("/admin/{}", T::ADMIN_NAME)))
}
});
let delete_db = db.clone();
router = router.post(&delete_path, move |req, params| {
let db = delete_db.clone();
async move {
if let Err(resp) = admin_guard(req.ctx()) {
return Ok(resp);
}
let id = parse_id_param(¶ms)?;
T::delete(&db, id).await?;
Ok::<Response, Error>(redirect(&format!("/admin/{}", T::ADMIN_NAME)))
}
});
router
}
fn parse_id_param(params: &crate::router::Params) -> Result<i64, Error> {
params
.get("id")
.and_then(|s| s.parse::<i64>().ok())
.ok_or_else(|| Error::BadRequest(String::from("invalid id")))
}
async fn read_form(req: Request) -> Result<FormData, Error> {
let (_, body, _) = req.into_parts();
let collected = body
.collect()
.await
.map_err(|e| Error::BadRequest(e.to_string()))?
.to_bytes();
let body_str = std::str::from_utf8(&collected).map_err(|e| Error::BadRequest(e.to_string()))?;
Ok(FormData::parse(body_str))
}
fn redirect(to: &str) -> Response {
hyper::Response::builder()
.status(303)
.header("location", to)
.body(Full::new(Bytes::new()))
.expect("valid redirect")
}
fn admin_layout(title: &str, content: &str) -> String {
format!(
r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title} — RustIO Admin</title>
<style>{css}</style>
</head>
<body>
<header><h1><a href="/admin">RustIO Admin</a></h1></header>
<main>{content}</main>
</body>
</html>"#,
title = escape_html(title),
css = ADMIN_CSS,
content = content,
)
}
fn index_page(entries: &[AdminEntry]) -> String {
if entries.is_empty() {
return String::from(
r#"<h2>Admin</h2>
<p class="empty">No models are registered. Add one with
<code>Admin::new().model::<YourModel>()</code> or scaffold an app
via <code>rustio new app <name></code>.</p>"#,
);
}
let rows: String = entries
.iter()
.map(|e| {
format!(
r#"<li><a href="/admin/{name}"><span class="label">{display}</span><span class="path">/admin/{name}</span></a></li>"#,
name = escape_html(e.admin_name),
display = escape_html(e.display_name),
)
})
.collect();
format!(
r#"<h2>Admin</h2>
<ul class="admin-index">{rows}</ul>"#
)
}
fn list_page<T: AdminModel>(items: &[T]) -> String {
let headers: String = T::FIELDS
.iter()
.map(|f| format!("<th>{}</th>", escape_html(f.name)))
.collect();
let rows: String = items
.iter()
.map(|item| {
let cells: String = T::FIELDS
.iter()
.map(|f| {
let v = item.field_display(f.name).unwrap_or_default();
format!("<td>{}</td>", escape_html(&v))
})
.collect();
let id = item.id();
let actions = format!(
r#"<td class="actions">
<a href="/admin/{name}/{id}/edit">edit</a>
<form method="post" action="/admin/{name}/{id}/delete">
<button type="submit" class="danger">delete</button>
</form>
</td>"#,
name = T::ADMIN_NAME,
id = id,
);
format!("<tr>{cells}{actions}</tr>")
})
.collect();
format!(
r#"<div class="toolbar">
<h2>{title}</h2>
<a class="button" href="/admin/{name}/create">New {singular}</a>
</div>
<table>
<thead><tr>{headers}<th>actions</th></tr></thead>
<tbody>{rows}</tbody>
</table>"#,
title = escape_html(T::DISPLAY_NAME),
singular = escape_html(T::singular_name()),
name = T::ADMIN_NAME,
)
}
fn form_page<T: AdminModel>(item: Option<&T>, action: &str) -> String {
let fields: String = T::FIELDS
.iter()
.filter(|f| f.editable)
.map(|f| render_field::<T>(f, item))
.collect();
let heading = if item.is_some() {
format!("Edit {}", T::singular_name())
} else {
format!("New {}", T::singular_name())
};
format!(
r#"<h2>{heading}</h2>
<form method="post" action="{action}">
{fields}
<div class="form-actions">
<button type="submit">Save</button>
<a class="cancel" href="/admin/{name}">Cancel</a>
</div>
</form>"#,
heading = escape_html(&heading),
action = escape_html(action),
name = T::ADMIN_NAME,
)
}
fn render_field<T: AdminModel>(f: &AdminField, item: Option<&T>) -> String {
let current = item
.and_then(|i| i.field_display(f.name))
.unwrap_or_default();
let input = match f.ty {
FieldType::Bool => format!(
r#"<input type="checkbox" name="{n}" {checked}>"#,
n = escape_html(f.name),
checked = if current == "true" { "checked" } else { "" },
),
FieldType::I32 | FieldType::I64 => format!(
r#"<input type="number" name="{n}" value="{v}">"#,
n = escape_html(f.name),
v = escape_html(¤t),
),
FieldType::String => format!(
r#"<input type="text" name="{n}" value="{v}">"#,
n = escape_html(f.name),
v = escape_html(¤t),
),
};
format!(
r#"<label><span>{label}</span>{input}</label>"#,
label = escape_html(f.name),
input = input,
)
}
#[allow(clippy::result_large_err)]
fn admin_guard(ctx: &crate::context::Context) -> Result<(), Response> {
match crate::auth::require_admin(ctx) {
Ok(_) => Ok(()),
Err(Error::Unauthorized) => Err(auth_error_html(401, "Authentication required")),
Err(Error::Forbidden) => Err(auth_error_html(403, "Forbidden")),
Err(other) => Err(other.into_response()),
}
}
fn auth_error_html(status: u16, title: &str) -> Response {
let hint = if crate::auth::in_production() {
String::new()
} else {
String::from(
r#"<p class="hint"><strong>Development mode.</strong> Authenticate with a dev token, e.g.:<br>
<code>curl -H "Authorization: Bearer dev-admin" http://127.0.0.1:8000/admin</code><br>
For browser access, use a header-injecting extension or replace
<code>authenticate</code> with your own middleware.</p>"#,
)
};
let body = format!(
r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{status} {title} — RustIO Admin</title>
<style>{css}</style>
</head>
<body>
<header><h1><a href="/admin">RustIO Admin</a></h1></header>
<main class="auth-error">
<div class="status">{status}</div>
<p class="heading">{title}</p>
{hint}
</main>
</body>
</html>"#,
status = status,
title = escape_html(title),
css = ADMIN_CSS,
hint = hint,
);
hyper::Response::builder()
.status(status)
.header("content-type", "text/html; charset=utf-8")
.body(Full::new(Bytes::from(body)))
.expect("valid response")
}
fn escape_html(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
c => out.push(c),
}
}
out
}
const ADMIN_CSS: &str = r#"
*, *::before, *::after { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #fafafa; color: #222; margin: 0; }
header { background: #222; color: white; padding: 1rem 2rem; }
header h1 { margin: 0; font-size: 1.1rem; font-weight: 600; letter-spacing: 0.02em; }
header h1 a { color: inherit; text-decoration: none; }
header h1 a:hover { opacity: 0.9; }
ul.admin-index { list-style: none; padding: 0; margin: 0; display: grid; gap: 0.5rem; }
ul.admin-index li { background: white; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
ul.admin-index li a { display: flex; justify-content: space-between; align-items: center; padding: 0.9rem 1.1rem; text-decoration: none; color: #222; }
ul.admin-index li a:hover { background: #f4f4f5; }
ul.admin-index li .label { font-weight: 600; }
ul.admin-index li .path { color: #888; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.85rem; }
p.empty { color: #666; }
p.empty code { background: #f0f0f2; padding: 0.1rem 0.35rem; border-radius: 3px; font-size: 0.9em; }
.auth-error { text-align: center; padding: 3rem 2rem; max-width: 36rem; margin: 0 auto; }
.auth-error .status { font-size: 3rem; font-weight: 700; color: #b42318; line-height: 1; }
.auth-error .heading { font-size: 1.15rem; margin: 0.5rem 0 1.5rem; font-weight: 600; color: #222; }
.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; }
.auth-error .hint code { background: #fdefe0; color: #7c2d12; padding: 0.1rem 0.35rem; border-radius: 3px; font-size: 0.9em; display: inline-block; }
main { padding: 2rem; max-width: 60rem; margin: 0 auto; }
h2 { margin: 0; }
.toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; }
table { border-collapse: collapse; width: 100%; background: white; border-radius: 6px; overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
th, td { text-align: left; padding: 0.6rem 0.9rem; border-bottom: 1px solid #eee; font-size: 0.95rem; }
th { background: #f4f4f5; font-weight: 600; }
tbody tr:last-child td { border-bottom: none; }
td.actions { display: flex; gap: 0.5rem; align-items: center; }
td.actions form { margin: 0; display: inline; }
a { color: #0366d6; text-decoration: none; }
a:hover { text-decoration: underline; }
label { display: block; margin-bottom: 1rem; }
label span { display: block; font-weight: 500; margin-bottom: 0.25rem; font-size: 0.9rem; }
input[type=text], input[type=number] { padding: 0.5rem 0.75rem; border: 1px solid #d0d0d4;
border-radius: 4px; width: 24rem; max-width: 100%; font: inherit; }
input[type=checkbox] { transform: scale(1.1); }
button, .button { padding: 0.5rem 1rem; background: #222; color: white; border: none;
border-radius: 4px; cursor: pointer; font: inherit; text-decoration: none; display: inline-block; }
button:hover, .button:hover { background: #000; text-decoration: none; }
button.danger { background: #b42318; }
button.danger:hover { background: #8a1c12; }
.form-actions { display: flex; gap: 0.5rem; align-items: center; margin-top: 1rem; }
.form-actions .cancel { color: #666; }
"#;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn escape_html_escapes_dangerous_chars() {
assert_eq!(
escape_html("<script>alert(\"xss\")</script>"),
"<script>alert("xss")</script>"
);
assert_eq!(escape_html("a & b"), "a & b");
assert_eq!(escape_html("it's"), "it's");
}
}