Oregami
Repositories/oxedyne/fe2o3

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
26use 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
40use oxedyne_fe2o3_core::prelude::*;
41use oxedyne_fe2o3_iop_crypto::enc::Encrypter;
42use oxedyne_fe2o3_iop_db::api::{
43 Database,
44 ScanOpts,
45};
46use oxedyne_fe2o3_iop_hash::api::Hasher;
47use oxedyne_fe2o3_jdat::{
48 daticle::Dat,
49 id::NumIdDat,
50};
51use oxedyne_fe2o3_net::http::{
52 fields::{
53 HeaderFields,
54 HeaderFieldValue,
55 HeaderName,
56 },
57 msg::HttpMessage,
58 status::HttpStatus,
59};
60
61use 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.
69pub const DEFAULT_LIST_LIMIT: usize = 500;
70pub const MAX_LIST_LIMIT: usize = 5_000;
71
72// ┌───────────────────────────────────────────────────────────────────────────┐
73// │ QUERY AND DETAIL STATE │
74// └───────────────────────────────────────────────────────────────────────────┘
75
76struct 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.
83struct DetailView {
84 key: String,
85 outcome: DetailOutcome, // rendered verbatim into the detail panel
86}
87
88enum 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.
107pub 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.
215fn 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.
258fn 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
324fn 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={}&amp;limit={}&amp;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
368fn 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
403fn 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
409fn 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.
431fn 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
448fn 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.
463fn 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
475fn 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
508fn 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