oxedyne/fe2o3/fe2o3_steel/src/srv/admin/ozone_view.rs
18.5 KiB, 198 runs
created by r1870400018:10328, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! Read-only ozone browser for the admin dashboard. |
| 2 | //! |
| 3 | //! Lists the live `(key, meta)` entries in the current vhost's |
| 4 | //! ozone database, with optional prefix filter and limit, and |
| 5 | //! renders them as an HTML table. A second query parameter |
| 6 | //! `key=<urlencoded>` selects one key and triggers a |
| 7 | //! `Database::get` to populate a detail panel alongside the list. |
| 8 | //! |
| 9 | //! # Routes |
| 10 | //! |
| 11 | //! - `GET /admin/database` -- list all keys for the current vhost's |
| 12 | //! ozone database, optionally filtered by `?prefix=<str>`, |
| 13 | //! capped by `?limit=<n>`, and with `?key=<str>` selecting a |
| 14 | //! single entry for the detail panel. |
| 15 | //! |
| 16 | //! # Auth |
| 17 | //! |
| 18 | //! Same gate as every other authenticated dashboard view: the |
| 19 | //! request must carry a valid session cookie whose principal |
| 20 | //! holds either `dashboard.view` or `dashboard.admin`. Failures |
| 21 | //! 303-redirect to `/admin/login`. |
| 22 | //! |
| 23 | //! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\ |
| 24 | //! Anthropic Claude |
| 25 | |
| 26 | use crate::srv::admin::{ |
| 27 | AdminPrincipal, |
| 28 | assets::{ |
| 29 | OZONE_LOGO_SVG, |
| 30 | html_escape, |
| 31 | render_layout, |
| 32 | }, |
| 33 | handler::{ |
| 34 | extract_principal, |
| 35 | redirect_to_login, |
| 36 | }, |
| 37 | state::AdminState, |
| 38 | }; |
| 39 | |
| 40 | use oxedyne_fe2o3_core::prelude::*; |
| 41 | use oxedyne_fe2o3_iop_crypto::enc::Encrypter; |
| 42 | use oxedyne_fe2o3_iop_db::api::{ |
| 43 | Database, |
| 44 | ScanOpts, |
| 45 | }; |
| 46 | use oxedyne_fe2o3_iop_hash::api::Hasher; |
| 47 | use oxedyne_fe2o3_jdat::{ |
| 48 | daticle::Dat, |
| 49 | id::NumIdDat, |
| 50 | }; |
| 51 | use oxedyne_fe2o3_net::http::{ |
| 52 | fields::{ |
| 53 | HeaderFields, |
| 54 | HeaderFieldValue, |
| 55 | HeaderName, |
| 56 | }, |
| 57 | msg::HttpMessage, |
| 58 | status::HttpStatus, |
| 59 | }; |
| 60 | |
| 61 | use std::sync::{ |
| 62 | Arc, |
| 63 | RwLock, |
| 64 | }; |
| 65 | |
| 66 | // The default bounds the wire response when the operator supplies no explicit |
| 67 | // `?limit=`; the maximum stops a malicious or accidental `?limit=999999999` |
| 68 | // from materialising the whole database into memory at once. |
| 69 | pub const DEFAULT_LIST_LIMIT: usize = 500; |
| 70 | pub const MAX_LIST_LIMIT: usize = 5_000; |
| 71 | |
| 72 | // ┌───────────────────────────────────────────────────────────────────────────┐ |
| 73 | // │ QUERY AND DETAIL STATE │ |
| 74 | // └───────────────────────────────────────────────────────────────────────────┘ |
| 75 | |
| 76 | struct ListQuery { |
| 77 | scan: ScanOpts, // forwarded to `Database::scan` |
| 78 | detail: Option<String>, // key to fetch for the detail panel, from `?key=` |
| 79 | } |
| 80 | |
| 81 | /// Non-generic snapshot of one key's detail, produced inside the generic |
| 82 | /// `handle_get` and passed to the non-generic renderer. |
| 83 | struct DetailView { |
| 84 | key: String, |
| 85 | outcome: DetailOutcome, // rendered verbatim into the detail panel |
| 86 | } |
| 87 | |
| 88 | enum DetailOutcome { |
| 89 | Found { |
| 90 | value_jdat: String, |
| 91 | meta_time: u64, |
| 92 | meta_user: String, |
| 93 | }, |
| 94 | Missing, |
| 95 | // The structural error itself is logged; only a short user-facing message |
| 96 | // reaches the page. |
| 97 | Error(String), |
| 98 | } |
| 99 | |
| 100 | // ┌───────────────────────────────────────────────────────────────────────────┐ |
| 101 | // │ GET │ |
| 102 | // └───────────────────────────────────────────────────────────────────────────┘ |
| 103 | |
| 104 | /// Generic over the database scheme, so the call sites in `app/https.rs` -- |
| 105 | /// which already carry these generics through `WebHandler::handle_get` -- can |
| 106 | /// pass `_db` straight in. |
| 107 | pub async fn handle_get< |
| 108 | const UIDL: usize, |
| 109 | UID: NumIdDat<UIDL>, |
| 110 | ENC: Encrypter, |
| 111 | KH: Hasher, |
| 112 | DB: Database<UIDL, UID, ENC, KH>, |
| 113 | >( |
| 114 | state: &AdminState, |
| 115 | db: Option<&(Arc<RwLock<DB>>, UID)>, |
| 116 | request_path: &str, |
| 117 | query: &str, |
| 118 | headers: &Arc<HeaderFields>, |
| 119 | id: &str, |
| 120 | ) |
| 121 | -> Outcome<HttpMessage> |
| 122 | { |
| 123 | debug!("{}: ozone view GET {}", id, request_path); |
| 124 | |
| 125 | // Auth gate first -- never reveal vhost db contents to an |
| 126 | // unauthenticated visitor. |
| 127 | let principal = match extract_principal(state, headers) { |
| 128 | Some(p) => p, |
| 129 | None => return Ok(redirect_to_login()), |
| 130 | }; |
| 131 | |
| 132 | // Strip the path prefix. v1 supports only the list route; future revisions |
| 133 | // will route /admin/database/<urlencoded_key> to a detail view. |
| 134 | // |
| 135 | // The query arrives as its own argument and is not cut out of the path: a |
| 136 | // request's path and query are parsed apart, so `request_path` never holds |
| 137 | // a `?`. This looked for one, never found it, and quietly read every |
| 138 | // request as though it carried no query at all -- so the prefix box, the |
| 139 | // limit box and the detail panel all did nothing, and the page always |
| 140 | // listed the first `DEFAULT_LIST_LIMIT` keys of the whole database. |
| 141 | let path_part = match request_path.strip_prefix("/admin/database") { |
| 142 | Some(s) => s, |
| 143 | None => return Ok(HttpMessage::respond_with_text( |
| 144 | HttpStatus::NotFound, |
| 145 | "Ozone route not found.", |
| 146 | )), |
| 147 | }; |
| 148 | let _ = path_part; // Sub-path routing reserved for future use. |
| 149 | |
| 150 | let parsed = parse_query(query); |
| 151 | |
| 152 | // Resolve the per-vhost database. A vhost without a configured |
| 153 | // ozone (typical for pure-redirect vhosts) renders a friendly |
| 154 | // empty-state page rather than 500. |
| 155 | let (db_arc, _uid) = match db { |
| 156 | Some(t) => t, |
| 157 | None => return Ok(render_no_db_page(&principal)), |
| 158 | }; |
| 159 | |
| 160 | let entries = { |
| 161 | let guard = lock_read!(db_arc); |
| 162 | match guard.scan(&parsed.scan, None) { |
| 163 | Ok(v) => v, |
| 164 | Err(e) => { |
| 165 | error!(e, "{}: ozone view scan failed", id); |
| 166 | return Ok(render_error_page( |
| 167 | &principal, |
| 168 | "Scan failed; check the server log.", |
| 169 | )); |
| 170 | }, |
| 171 | } |
| 172 | }; |
| 173 | |
| 174 | // Scan returns `Dat::Empty` values; keep only the keys for |
| 175 | // the list renderer so the render side stays non-generic. |
| 176 | let keys: Vec<Dat> = entries.into_iter().map(|(k, _, _)| k).collect(); |
| 177 | |
| 178 | // If a detail key was supplied, fetch it via `Database::get` |
| 179 | // and marshal the result into a non-generic snapshot so the |
| 180 | // renderer does not need to carry UID generics. |
| 181 | let detail = match &parsed.detail { |
| 182 | Some(key_str) => { |
| 183 | let dat_key = Dat::Str(key_str.clone()); |
| 184 | let outcome = { |
| 185 | let guard = lock_read!(db_arc); |
| 186 | match guard.get(&dat_key, None) { |
| 187 | Ok(Some((val, meta))) => DetailOutcome::Found { |
| 188 | value_jdat: fmt!("{}", val), |
| 189 | meta_time: meta.time.secs(), |
| 190 | meta_user: fmt!("{:?}", meta.user), |
| 191 | }, |
| 192 | Ok(None) => DetailOutcome::Missing, |
| 193 | Err(e) => { |
| 194 | error!(e, "{}: ozone get failed for key {:?}", id, key_str); |
| 195 | DetailOutcome::Error( |
| 196 | "Fetch failed; check the server log.".to_string(), |
| 197 | ) |
| 198 | }, |
| 199 | } |
| 200 | }; |
| 201 | Some(DetailView { key: key_str.clone(), outcome }) |
| 202 | }, |
| 203 | None => None, |
| 204 | }; |
| 205 | |
| 206 | Ok(render_list_page(&principal, &parsed, &keys, detail.as_ref())) |
| 207 | } |
| 208 | |
| 209 | // ┌───────────────────────────────────────────────────────────────────────────┐ |
| 210 | // │ QUERY PARSING │ |
| 211 | // └───────────────────────────────────────────────────────────────────────────┘ |
| 212 | |
| 213 | /// Recognises `prefix=<str>`, `limit=<u32>` and `key=<str>`. Unknown keys are |
| 214 | /// ignored; a malformed limit falls back to the default. |
| 215 | fn parse_query(query: &str) -> ListQuery { |
| 216 | let mut scan = ScanOpts::default(); |
| 217 | scan.limit = Some(DEFAULT_LIST_LIMIT); |
| 218 | let mut detail: Option<String> = None; |
| 219 | if query.is_empty() { |
| 220 | return ListQuery { scan, detail }; |
| 221 | } |
| 222 | for pair in query.split('&') { |
| 223 | let mut kv = pair.splitn(2, '='); |
| 224 | let k = match kv.next() { Some(k) => k, None => continue }; |
| 225 | let v = kv.next().unwrap_or(""); |
| 226 | match k { |
| 227 | "prefix" => { |
| 228 | let decoded = url_decode(v); |
| 229 | if !decoded.is_empty() { |
| 230 | scan.prefix = Some(Dat::Str(decoded)); |
| 231 | } |
| 232 | }, |
| 233 | "limit" => { |
| 234 | if let Ok(n) = v.parse::<usize>() { |
| 235 | scan.limit = Some(n.min(MAX_LIST_LIMIT)); |
| 236 | } |
| 237 | }, |
| 238 | "key" => { |
| 239 | let decoded = url_decode(v); |
| 240 | if !decoded.is_empty() { |
| 241 | detail = Some(decoded); |
| 242 | } |
| 243 | }, |
| 244 | _ => (), |
| 245 | } |
| 246 | } |
| 247 | ListQuery { scan, detail } |
| 248 | } |
| 249 | |
| 250 | // ┌───────────────────────────────────────────────────────────────────────────┐ |
| 251 | // │ RENDER │ |
| 252 | // └───────────────────────────────────────────────────────────────────────────┘ |
| 253 | |
| 254 | /// Each row in the key table links to `?prefix=...&limit=...&key=<urlencoded>`, |
| 255 | /// so clicking a key refreshes the same page with the detail panel populated. |
| 256 | /// The prefix and limit inputs are preserved, so the list context survives a |
| 257 | /// selection. |
| 258 | fn render_list_page( |
| 259 | principal: &AdminPrincipal, |
| 260 | parsed: &ListQuery, |
| 261 | keys: &[Dat], |
| 262 | detail: Option<&DetailView>, |
| 263 | ) -> HttpMessage { |
| 264 | let prefix_raw = match &parsed.scan.prefix { |
| 265 | Some(Dat::Str(s)) => s.clone(), |
| 266 | _ => String::new(), |
| 267 | }; |
| 268 | let limit_val = parsed.scan.limit.unwrap_or(DEFAULT_LIST_LIMIT); |
| 269 | let selected_key = detail.map(|d| d.key.as_str()).unwrap_or(""); |
| 270 | |
| 271 | let form = fmt!( |
| 272 | "<form class=\"steel-form\" method=\"GET\" action=\"/admin/database\">\n\ |
| 273 | <div class=\"row\">\n\ |
| 274 | <div>\n\ |
| 275 | <label for=\"prefix\">Prefix</label>\n\ |
| 276 | <input type=\"text\" id=\"prefix\" name=\"prefix\" \ |
| 277 | value=\"{prefix}\" placeholder=\"e.g. user:\">\n\ |
| 278 | </div>\n\ |
| 279 | <div>\n\ |
| 280 | <label for=\"limit\">Limit</label>\n\ |
| 281 | <input type=\"number\" id=\"limit\" name=\"limit\" \ |
| 282 | value=\"{limit}\" min=\"1\" max=\"{maxlim}\">\n\ |
| 283 | </div>\n\ |
| 284 | </div>\n\ |
| 285 | <button type=\"submit\">Search</button>\n\ |
| 286 | </form>\n", |
| 287 | prefix = html_escape(&prefix_raw), |
| 288 | limit = limit_val, |
| 289 | maxlim = MAX_LIST_LIMIT, |
| 290 | ); |
| 291 | |
| 292 | let table = render_key_table(keys, &prefix_raw, limit_val, selected_key); |
| 293 | let detail_html = match detail { |
| 294 | Some(d) => render_detail_panel(d), |
| 295 | None => render_detail_placeholder(), |
| 296 | }; |
| 297 | |
| 298 | let body = fmt!( |
| 299 | "<h1><span class=\"heading-logo\">{logo}</span>Database</h1>\n\ |
| 300 | <p class=\"meta\">Showing {count} entries (cap {limit}).</p>\n\ |
| 301 | {form}\ |
| 302 | <div class=\"ozone-split\">\n\ |
| 303 | <div class=\"ozone-list\">{table}</div>\n\ |
| 304 | <aside class=\"ozone-detail\">{detail}</aside>\n\ |
| 305 | </div>\n", |
| 306 | logo = OZONE_LOGO_SVG, |
| 307 | count = keys.len(), |
| 308 | limit = limit_val, |
| 309 | form = form, |
| 310 | table = table, |
| 311 | detail = detail_html, |
| 312 | ); |
| 313 | |
| 314 | let html = render_layout( |
| 315 | "Database", |
| 316 | "/admin/database", |
| 317 | principal, |
| 318 | &body, |
| 319 | "", |
| 320 | ); |
| 321 | html_response(html) |
| 322 | } |
| 323 | |
| 324 | fn render_key_table( |
| 325 | keys: &[Dat], |
| 326 | prefix_raw: &str, |
| 327 | limit_val: usize, |
| 328 | selected_key: &str, |
| 329 | ) |
| 330 | -> String |
| 331 | { |
| 332 | if keys.is_empty() { |
| 333 | return "<p class=\"notice empty\">No keys match this filter.</p>".to_string(); |
| 334 | } |
| 335 | let mut rows = String::new(); |
| 336 | for k in keys.iter() { |
| 337 | let key_str = match k { |
| 338 | Dat::Str(s) => s.clone(), |
| 339 | other => fmt!("{:?}", other), |
| 340 | }; |
| 341 | let href = fmt!( |
| 342 | "/admin/database?prefix={}&limit={}&key={}", |
| 343 | url_encode(prefix_raw), |
| 344 | limit_val, |
| 345 | url_encode(&key_str), |
| 346 | ); |
| 347 | let selected_attr = if key_str == selected_key { |
| 348 | " class=\"selected\"" |
| 349 | } else { |
| 350 | "" |
| 351 | }; |
| 352 | rows.push_str(&fmt!( |
| 353 | "<tr{sel}><td><a href=\"{href}\"><code>{label}</code></a></td></tr>\n", |
| 354 | sel = selected_attr, |
| 355 | href = href, |
| 356 | label = html_escape(&key_str), |
| 357 | )); |
| 358 | } |
| 359 | fmt!( |
| 360 | "<table class=\"steel-table\">\n\ |
| 361 | <thead><tr><th>Key</th></tr></thead>\n\ |
| 362 | <tbody>\n{}</tbody>\n\ |
| 363 | </table>\n", |
| 364 | rows, |
| 365 | ) |
| 366 | } |
| 367 | |
| 368 | fn render_detail_panel(d: &DetailView) -> String { |
| 369 | match &d.outcome { |
| 370 | DetailOutcome::Found { value_jdat, meta_time, meta_user } => fmt!( |
| 371 | "<h3>Selected key</h3>\n\ |
| 372 | <div class=\"field\"><span class=\"lbl\">Key</span>\ |
| 373 | <span class=\"val\"><code>{key}</code></span></div>\n\ |
| 374 | <div class=\"field\"><span class=\"lbl\">Modified (unix s)</span>\ |
| 375 | <span class=\"val\">{time}</span></div>\n\ |
| 376 | <div class=\"field\"><span class=\"lbl\">Meta user</span>\ |
| 377 | <span class=\"val\">{user}</span></div>\n\ |
| 378 | <div class=\"field\"><span class=\"lbl\">Value (JDAT)</span>\ |
| 379 | <pre class=\"val\">{value}</pre></div>\n", |
| 380 | key = html_escape(&d.key), |
| 381 | time = meta_time, |
| 382 | user = html_escape(meta_user), |
| 383 | value = html_escape(value_jdat), |
| 384 | ), |
| 385 | DetailOutcome::Missing => fmt!( |
| 386 | "<h3>Selected key</h3>\n\ |
| 387 | <div class=\"field\"><span class=\"lbl\">Key</span>\ |
| 388 | <span class=\"val\"><code>{key}</code></span></div>\n\ |
| 389 | <p class=\"notice empty\">Key not present in the database.</p>\n", |
| 390 | key = html_escape(&d.key), |
| 391 | ), |
| 392 | DetailOutcome::Error(msg) => fmt!( |
| 393 | "<h3>Selected key</h3>\n\ |
| 394 | <div class=\"field\"><span class=\"lbl\">Key</span>\ |
| 395 | <span class=\"val\"><code>{key}</code></span></div>\n\ |
| 396 | <p class=\"notice error\">{msg}</p>\n", |
| 397 | key = html_escape(&d.key), |
| 398 | msg = html_escape(msg), |
| 399 | ), |
| 400 | } |
| 401 | } |
| 402 | |
| 403 | fn render_detail_placeholder() -> String { |
| 404 | "<h3>Selected key</h3>\n\ |
| 405 | <p class=\"notice empty\">Pick a key from the list to see its \ |
| 406 | value and metadata.</p>\n".to_string() |
| 407 | } |
| 408 | |
| 409 | fn render_no_db_page(principal: &AdminPrincipal) -> HttpMessage { |
| 410 | let body = fmt!( |
| 411 | "<h1><span class=\"heading-logo\">{}</span>Database</h1>\n\ |
| 412 | <p class=\"notice empty\">\ |
| 413 | This vhost does not have an ozone database configured. \ |
| 414 | Pure-redirect vhosts and static-only vhosts do not store \ |
| 415 | anything in ozone, so there is nothing to browse here.\ |
| 416 | </p>\n", |
| 417 | OZONE_LOGO_SVG, |
| 418 | ); |
| 419 | let html = render_layout( |
| 420 | "Database", |
| 421 | "/admin/database", |
| 422 | principal, |
| 423 | &body, |
| 424 | "", |
| 425 | ); |
| 426 | html_response(html) |
| 427 | } |
| 428 | |
| 429 | /// Keeps the structural error out of the response body; the operator reads the |
| 430 | /// server log for the underlying cause. |
| 431 | fn render_error_page(principal: &AdminPrincipal, message: &str) -> HttpMessage { |
| 432 | let body = fmt!( |
| 433 | "<h1><span class=\"heading-logo\">{}</span>Database</h1>\n\ |
| 434 | <p class=\"notice error\">{}</p>\n", |
| 435 | OZONE_LOGO_SVG, |
| 436 | html_escape(message), |
| 437 | ); |
| 438 | let html = render_layout( |
| 439 | "Database", |
| 440 | "/admin/database", |
| 441 | principal, |
| 442 | &body, |
| 443 | "", |
| 444 | ); |
| 445 | html_response(html) |
| 446 | } |
| 447 | |
| 448 | fn html_response(body: String) -> HttpMessage { |
| 449 | HttpMessage::new_response(HttpStatus::OK) |
| 450 | .with_field( |
| 451 | HeaderName::ContentType, |
| 452 | HeaderFieldValue::Generic("text/html; charset=utf-8".to_string()), |
| 453 | ) |
| 454 | .with_body(body.into_bytes()) |
| 455 | } |
| 456 | |
| 457 | // ┌───────────────────────────────────────────────────────────────────────────┐ |
| 458 | // │ HELPERS │ |
| 459 | // └───────────────────────────────────────────────────────────────────────────┘ |
| 460 | |
| 461 | /// Percent-escapes every byte that is not an unreserved ASCII character, per |
| 462 | /// RFC 3986 section 2.3. |
| 463 | fn url_encode(s: &str) -> String { |
| 464 | let mut out = String::with_capacity(s.len()); |
| 465 | for b in s.as_bytes().iter() { |
| 466 | match *b { |
| 467 | b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' |
| 468 | | b'-' | b'_' | b'.' | b'~' => out.push(*b as char), |
| 469 | other => out.push_str(&fmt!("%{:02X}", other)), |
| 470 | } |
| 471 | } |
| 472 | out |
| 473 | } |
| 474 | |
| 475 | fn url_decode(s: &str) -> String { |
| 476 | let bytes = s.as_bytes(); |
| 477 | let mut out = Vec::with_capacity(bytes.len()); |
| 478 | let mut i = 0; |
| 479 | while i < bytes.len() { |
| 480 | match bytes[i] { |
| 481 | b'+' => { |
| 482 | out.push(b' '); |
| 483 | i += 1; |
| 484 | }, |
| 485 | b'%' if i + 2 < bytes.len() => { |
| 486 | let hi = hex_nibble(bytes[i + 1]); |
| 487 | let lo = hex_nibble(bytes[i + 2]); |
| 488 | match (hi, lo) { |
| 489 | (Some(h), Some(l)) => { |
| 490 | out.push((h << 4) | l); |
| 491 | i += 3; |
| 492 | }, |
| 493 | _ => { |
| 494 | out.push(bytes[i]); |
| 495 | i += 1; |
| 496 | }, |
| 497 | } |
| 498 | }, |
| 499 | b => { |
| 500 | out.push(b); |
| 501 | i += 1; |
| 502 | }, |
| 503 | } |
| 504 | } |
| 505 | String::from_utf8_lossy(&out).into_owned() |
| 506 | } |
| 507 | |
| 508 | fn hex_nibble(b: u8) -> Option<u8> { |
| 509 | match b { |
| 510 | b'0'..=b'9' => Some(b - b'0'), |
| 511 | b'a'..=b'f' => Some(10 + b - b'a'), |
| 512 | b'A'..=b'F' => Some(10 + b - b'A'), |
| 513 | _ => None, |
| 514 | } |
| 515 | } |
| 516 |