1use bytes::Bytes;
8use http_body_util::{BodyExt, Full};
9
10use crate::auth::require_admin;
11use crate::error::Error;
12use crate::http::{html, Request, Response};
13use crate::orm::{Db, Model};
14use crate::router::Router;
15
16pub use crate::http::FormData;
20
21#[derive(Debug, Clone, Copy)]
22pub enum FieldType {
23 I32,
24 I64,
25 String,
26 Bool,
27}
28
29#[derive(Debug, Clone, Copy)]
30pub struct AdminField {
31 pub name: &'static str,
32 pub ty: FieldType,
33 pub editable: bool,
34}
35
36pub trait AdminModel: Model {
37 const ADMIN_NAME: &'static str;
38 const DISPLAY_NAME: &'static str;
39 const FIELDS: &'static [AdminField];
40
41 fn field_display(&self, name: &str) -> Option<String>;
42 fn from_form(form: &FormData, id: Option<i64>) -> Result<Self, Error>;
43}
44
45pub fn register<T>(mut router: Router, db: &Db) -> Router
46where
47 T: AdminModel + Model,
48{
49 let base = format!("/admin/{}", T::ADMIN_NAME);
50 let create_path = format!("{base}/create");
51 let edit_path = format!("{base}/:id/edit");
52 let delete_path = format!("{base}/:id/delete");
53
54 let list_db = db.clone();
55 router = router.get(&base, move |req, _params| {
56 let db = list_db.clone();
57 async move {
58 require_admin(req.ctx())?;
59 let items = T::all(&db).await?;
60 Ok::<Response, Error>(html(admin_layout(T::DISPLAY_NAME, &list_page::<T>(&items))))
61 }
62 });
63
64 router = router.get(&create_path, |req, _params| async move {
65 require_admin(req.ctx())?;
66 Ok::<Response, Error>(html(admin_layout(
67 &format!("New {}", T::DISPLAY_NAME),
68 &form_page::<T>(None, &format!("/admin/{}/create", T::ADMIN_NAME)),
69 )))
70 });
71
72 let create_db = db.clone();
73 router = router.post(&create_path, move |req, _params| {
74 let db = create_db.clone();
75 async move {
76 require_admin(req.ctx())?;
77 let form = read_form(req).await?;
78 let item = T::from_form(&form, None)?;
79 item.create(&db).await?;
80 Ok::<Response, Error>(redirect(&format!("/admin/{}", T::ADMIN_NAME)))
81 }
82 });
83
84 let edit_db = db.clone();
85 router = router.get(&edit_path, move |req, params| {
86 let db = edit_db.clone();
87 async move {
88 require_admin(req.ctx())?;
89 let id = parse_id_param(¶ms)?;
90 let item = T::find(&db, id).await?.ok_or(Error::NotFound)?;
91 Ok::<Response, Error>(html(admin_layout(
92 &format!("Edit {}", T::DISPLAY_NAME),
93 &form_page::<T>(
94 Some(&item),
95 &format!("/admin/{}/{}/edit", T::ADMIN_NAME, id),
96 ),
97 )))
98 }
99 });
100
101 let update_db = db.clone();
102 router = router.post(&edit_path, move |req, params| {
103 let db = update_db.clone();
104 async move {
105 require_admin(req.ctx())?;
106 let id = parse_id_param(¶ms)?;
107 let form = read_form(req).await?;
108 let item = T::from_form(&form, Some(id))?;
109 item.update(&db).await?;
110 Ok::<Response, Error>(redirect(&format!("/admin/{}", T::ADMIN_NAME)))
111 }
112 });
113
114 let delete_db = db.clone();
115 router = router.post(&delete_path, move |req, params| {
116 let db = delete_db.clone();
117 async move {
118 require_admin(req.ctx())?;
119 let id = parse_id_param(¶ms)?;
120 T::delete(&db, id).await?;
121 Ok::<Response, Error>(redirect(&format!("/admin/{}", T::ADMIN_NAME)))
122 }
123 });
124
125 router
126}
127
128fn parse_id_param(params: &crate::router::Params) -> Result<i64, Error> {
129 params
130 .get("id")
131 .and_then(|s| s.parse::<i64>().ok())
132 .ok_or_else(|| Error::BadRequest(String::from("invalid id")))
133}
134
135async fn read_form(req: Request) -> Result<FormData, Error> {
136 let (_, body, _) = req.into_parts();
137 let collected = body
138 .collect()
139 .await
140 .map_err(|e| Error::BadRequest(e.to_string()))?
141 .to_bytes();
142 let body_str = std::str::from_utf8(&collected).map_err(|e| Error::BadRequest(e.to_string()))?;
143 Ok(FormData::parse(body_str))
144}
145
146fn redirect(to: &str) -> Response {
147 hyper::Response::builder()
148 .status(303)
149 .header("location", to)
150 .body(Full::new(Bytes::new()))
151 .expect("valid redirect")
152}
153
154fn admin_layout(title: &str, content: &str) -> String {
155 format!(
156 r#"<!doctype html>
157<html lang="en">
158<head>
159<meta charset="utf-8">
160<meta name="viewport" content="width=device-width, initial-scale=1">
161<title>{title} — RustIO Admin</title>
162<style>{css}</style>
163</head>
164<body>
165<header><h1>RustIO Admin</h1></header>
166<main>{content}</main>
167</body>
168</html>"#,
169 title = escape_html(title),
170 css = ADMIN_CSS,
171 content = content,
172 )
173}
174
175fn list_page<T: AdminModel>(items: &[T]) -> String {
176 let headers: String = T::FIELDS
177 .iter()
178 .map(|f| format!("<th>{}</th>", escape_html(f.name)))
179 .collect();
180 let rows: String = items
181 .iter()
182 .map(|item| {
183 let cells: String = T::FIELDS
184 .iter()
185 .map(|f| {
186 let v = item.field_display(f.name).unwrap_or_default();
187 format!("<td>{}</td>", escape_html(&v))
188 })
189 .collect();
190 let id = item.id();
191 let actions = format!(
192 r#"<td class="actions">
193<a href="/admin/{name}/{id}/edit">edit</a>
194<form method="post" action="/admin/{name}/{id}/delete">
195<button type="submit" class="danger">delete</button>
196</form>
197</td>"#,
198 name = T::ADMIN_NAME,
199 id = id,
200 );
201 format!("<tr>{cells}{actions}</tr>")
202 })
203 .collect();
204
205 format!(
206 r#"<div class="toolbar">
207<h2>{title}</h2>
208<a class="button" href="/admin/{name}/create">New {display}</a>
209</div>
210<table>
211<thead><tr>{headers}<th>actions</th></tr></thead>
212<tbody>{rows}</tbody>
213</table>"#,
214 title = escape_html(T::DISPLAY_NAME),
215 display = escape_html(T::DISPLAY_NAME),
216 name = T::ADMIN_NAME,
217 )
218}
219
220fn form_page<T: AdminModel>(item: Option<&T>, action: &str) -> String {
221 let fields: String = T::FIELDS
222 .iter()
223 .filter(|f| f.editable)
224 .map(|f| render_field::<T>(f, item))
225 .collect();
226 let heading = if item.is_some() {
227 format!("Edit {}", T::DISPLAY_NAME)
228 } else {
229 format!("New {}", T::DISPLAY_NAME)
230 };
231 format!(
232 r#"<h2>{heading}</h2>
233<form method="post" action="{action}">
234{fields}
235<div class="form-actions">
236<button type="submit">Save</button>
237<a class="cancel" href="/admin/{name}">Cancel</a>
238</div>
239</form>"#,
240 heading = escape_html(&heading),
241 action = escape_html(action),
242 name = T::ADMIN_NAME,
243 )
244}
245
246fn render_field<T: AdminModel>(f: &AdminField, item: Option<&T>) -> String {
247 let current = item
248 .and_then(|i| i.field_display(f.name))
249 .unwrap_or_default();
250 let input = match f.ty {
251 FieldType::Bool => format!(
252 r#"<input type="checkbox" name="{n}" {checked}>"#,
253 n = escape_html(f.name),
254 checked = if current == "true" { "checked" } else { "" },
255 ),
256 FieldType::I32 | FieldType::I64 => format!(
257 r#"<input type="number" name="{n}" value="{v}">"#,
258 n = escape_html(f.name),
259 v = escape_html(¤t),
260 ),
261 FieldType::String => format!(
262 r#"<input type="text" name="{n}" value="{v}">"#,
263 n = escape_html(f.name),
264 v = escape_html(¤t),
265 ),
266 };
267 format!(
268 r#"<label><span>{label}</span>{input}</label>"#,
269 label = escape_html(f.name),
270 input = input,
271 )
272}
273
274fn escape_html(s: &str) -> String {
275 let mut out = String::with_capacity(s.len());
276 for ch in s.chars() {
277 match ch {
278 '&' => out.push_str("&"),
279 '<' => out.push_str("<"),
280 '>' => out.push_str(">"),
281 '"' => out.push_str("""),
282 '\'' => out.push_str("'"),
283 c => out.push(c),
284 }
285 }
286 out
287}
288
289const ADMIN_CSS: &str = r#"
290*, *::before, *::after { box-sizing: border-box; }
291body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
292 background: #fafafa; color: #222; margin: 0; }
293header { background: #222; color: white; padding: 1rem 2rem; }
294header h1 { margin: 0; font-size: 1.1rem; font-weight: 600; letter-spacing: 0.02em; }
295main { padding: 2rem; max-width: 60rem; margin: 0 auto; }
296h2 { margin: 0; }
297.toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; }
298table { border-collapse: collapse; width: 100%; background: white; border-radius: 6px; overflow: hidden;
299 box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
300th, td { text-align: left; padding: 0.6rem 0.9rem; border-bottom: 1px solid #eee; font-size: 0.95rem; }
301th { background: #f4f4f5; font-weight: 600; }
302tbody tr:last-child td { border-bottom: none; }
303td.actions { display: flex; gap: 0.5rem; align-items: center; }
304td.actions form { margin: 0; display: inline; }
305a { color: #0366d6; text-decoration: none; }
306a:hover { text-decoration: underline; }
307label { display: block; margin-bottom: 1rem; }
308label span { display: block; font-weight: 500; margin-bottom: 0.25rem; font-size: 0.9rem; }
309input[type=text], input[type=number] { padding: 0.5rem 0.75rem; border: 1px solid #d0d0d4;
310 border-radius: 4px; width: 24rem; max-width: 100%; font: inherit; }
311input[type=checkbox] { transform: scale(1.1); }
312button, .button { padding: 0.5rem 1rem; background: #222; color: white; border: none;
313 border-radius: 4px; cursor: pointer; font: inherit; text-decoration: none; display: inline-block; }
314button:hover, .button:hover { background: #000; text-decoration: none; }
315button.danger { background: #b42318; }
316button.danger:hover { background: #8a1c12; }
317.form-actions { display: flex; gap: 0.5rem; align-items: center; margin-top: 1rem; }
318.form-actions .cancel { color: #666; }
319"#;
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324
325 #[test]
326 fn escape_html_escapes_dangerous_chars() {
327 assert_eq!(
328 escape_html("<script>alert(\"xss\")</script>"),
329 "<script>alert("xss")</script>"
330 );
331 assert_eq!(escape_html("a & b"), "a & b");
332 assert_eq!(escape_html("it's"), "it's");
333 }
334}