1mod kv_views;
69mod sql_views;
70
71use axum::extract::Request;
72use axum::http::StatusCode;
73use axum::middleware::Next;
74use axum::response::{Html, IntoResponse, Redirect, Response};
75use larust_core::AppError;
76use larust_http::session::Session;
77use serde::Deserialize;
78use std::sync::OnceLock;
79
80const SESSION_KEY: &str = "_larust_db_dashboard_authed";
81const DEFAULT_DASHBOARD_PATH: &str = "xr-db";
82
83static DASHBOARD_PATH: OnceLock<String> = OnceLock::new();
84
85pub(crate) fn dashboard_path() -> &'static str {
92 DASHBOARD_PATH.get_or_init(|| {
93 std::env::var("DB_DASHBOARD_PATH")
94 .ok()
95 .map(|raw| raw.trim_matches('/').to_string())
96 .filter(|path| !path.is_empty())
97 .unwrap_or_else(|| DEFAULT_DASHBOARD_PATH.to_string())
98 })
99}
100
101pub struct DbPlugin;
109
110impl larust_http::Plugin for DbPlugin {
111 fn routes(&self) -> larust_http::Router {
112 let base = dashboard_path();
113 larust_http::Router::new()
114 .get(&format!("/{base}/login"), login_form)
115 .get(&format!("/{base}/login/"), login_form)
116 .post(&format!("/{base}/login"), login)
117 .group("", |r| {
118 r.middleware(axum::middleware::from_fn(require_db_login))
119 .get(&format!("/{base}"), sql_views::table_list)
120 .get(&format!("/{base}/"), sql_views::table_list)
121 .get(&format!("/{base}/sql"), sql_views::sql_form)
122 .post(&format!("/{base}/sql"), sql_views::sql_run)
123 .get(&format!("/{base}/import"), sql_views::import_form)
124 .post(&format!("/{base}/import"), sql_views::import_run)
125 .post(&format!("/{base}/migrate/fresh"), sql_views::migrate_fresh)
126 .get(&format!("/{base}/t/{{table}}"), sql_views::browse)
127 .get(
128 &format!("/{base}/t/{{table}}/structure"),
129 sql_views::structure,
130 )
131 .get(&format!("/{base}/t/{{table}}/new"), sql_views::new_form)
132 .post(&format!("/{base}/t/{{table}}/insert"), sql_views::insert)
133 .get(&format!("/{base}/t/{{table}}/edit"), sql_views::edit_form)
134 .post(&format!("/{base}/t/{{table}}/update"), sql_views::update)
135 .post(&format!("/{base}/t/{{table}}/delete"), sql_views::delete)
136 .get(&format!("/{base}/kv"), kv_views::index)
137 .post(&format!("/{base}/kv/set"), kv_views::set)
138 .post(&format!("/{base}/kv/{{key}}/delete"), kv_views::delete)
139 .post(&format!("/{base}/logout"), logout)
140 })
141 }
142}
143
144static PASSWORD_HASH: OnceLock<Option<String>> = OnceLock::new();
145
146fn configured_password_hash() -> Option<&'static str> {
150 PASSWORD_HASH
151 .get_or_init(|| {
152 std::env::var("DB_DASHBOARD_PASSWORD")
153 .ok()
154 .filter(|password| !password.is_empty())
155 .map(|password| {
156 larust_auth::hash_password(&password)
157 .expect("hashing the configured DB_DASHBOARD_PASSWORD")
158 })
159 })
160 .as_deref()
161}
162
163fn dashboard_disabled() -> Response {
164 (
165 StatusCode::SERVICE_UNAVAILABLE,
166 "The embedded DB dashboard is disabled - set DB_DASHBOARD_PASSWORD in .env to enable it.",
167 )
168 .into_response()
169}
170
171async fn is_authed(session: &Session) -> Result<bool, AppError> {
172 Ok(session
173 .get::<bool>(SESSION_KEY)
174 .await
175 .map_err(internal)?
176 .unwrap_or(false))
177}
178
179pub async fn require_db_login(session: Session, request: Request, next: Next) -> Response {
184 if configured_password_hash().is_none() {
185 return dashboard_disabled();
186 }
187 match is_authed(&session).await {
188 Ok(true) => next.run(request).await,
189 Ok(false) => Redirect::to(&format!("/{}/login", dashboard_path())).into_response(),
190 Err(error) => {
191 tracing::warn!(%error, "db dashboard: failed to read session; denying access");
192 Redirect::to(&format!("/{}/login", dashboard_path())).into_response()
193 }
194 }
195}
196
197pub async fn login_form(session: Session) -> Response {
198 if configured_password_hash().is_none() {
199 return dashboard_disabled();
200 }
201 let csrf = larust_http::csrf::token(&session).await;
202 Html(render_login_page(&csrf, None)).into_response()
203}
204
205#[derive(Deserialize)]
206pub struct LoginForm {
207 password: String,
208}
209
210pub async fn login(session: Session, axum::Form(form): axum::Form<LoginForm>) -> Response {
211 let Some(hash) = configured_password_hash() else {
212 return dashboard_disabled();
213 };
214 let ok = larust_auth::verify_password(hash, &form.password).unwrap_or(false);
215 if !ok {
216 let csrf = larust_http::csrf::token(&session).await;
217 return Html(render_login_page(&csrf, Some("Incorrect password."))).into_response();
218 }
219 if let Err(error) = session.insert(SESSION_KEY, true).await {
220 tracing::warn!(%error, "db dashboard: failed to persist login session flag");
221 }
222 Redirect::to(&format!("/{}", dashboard_path())).into_response()
223}
224
225pub async fn logout(session: Session) -> Result<Redirect, AppError> {
226 session
227 .remove::<bool>(SESSION_KEY)
228 .await
229 .map_err(internal)?;
230 Ok(Redirect::to(&format!("/{}/login", dashboard_path())))
231}
232
233fn brand_html(size_class: &str) -> String {
238 format!(
239 r#"<div class="brand {size_class}">
240 <span class="brand-mark">>_</span>
241 <span class="brand-name">larust <strong>db</strong></span>
242</div>"#
243 )
244}
245
246#[derive(Clone, Copy, PartialEq, Eq)]
249pub(crate) enum Section {
250 Database,
251 Sql,
252 Kv,
253}
254
255pub(crate) fn path_segment(s: &str) -> String {
265 let mut out = String::with_capacity(s.len());
266 for byte in s.bytes() {
267 match byte {
268 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
269 out.push(byte as char);
270 }
271 _ => out.push_str(&format!("%{byte:02X}")),
272 }
273 }
274 out
275}
276
277const FAVICON_DATA_URI: &str = "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyBpZD0iTGF5ZXJfMSIgZGF0YS1uYW1lPSJMYXllciAxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0OCA0OCI+CiAgPGRlZnM+CiAgICA8c3R5bGU+CiAgICAgIC5jbHMtMSB7CiAgICAgICAgZmlsbDogI2ZmZjsKICAgICAgfQoKICAgICAgLmNscy0yIHsKICAgICAgICBmaWxsOiAjZmY3MzVmOwogICAgICB9CiAgICA8L3N0eWxlPgogIDwvZGVmcz4KICA8cGF0aCBpZD0iUmVjdGFuZ2xlXzEiIGRhdGEtbmFtZT0iUmVjdGFuZ2xlIDEiIGNsYXNzPSJjbHMtMiIgZD0iTTEyLDBoMjRjNi42MywwLDEyLDUuMzcsMTIsMTJ2MjRjMCw2LjYzLTUuMzcsMTItMTIsMTJIMFYxMkMwLDUuMzcsNS4zNywwLDEyLDBaIi8+CiAgPGcgaWQ9IlBhdGhfMSIgZGF0YS1uYW1lPSJQYXRoIDEiPgogICAgPHBhdGggY2xhc3M9ImNscy0xIiBkPSJNMTMuMjUsMzAuNTljLS4yMywwLS40Ni0uMDgtLjY0LS4yMy0uNDItLjM2LS40OC0uOTktLjEyLTEuNDFsNC43LTUuNTktNC42OS01LjQyYy0uMzYtLjQyLS4zMi0xLjA1LjEtMS40MS40Mi0uMzYsMS4wNS0uMzIsMS40MS4xbDUuMjUsNi4wN2MuMzIuMzcuMzMuOTIsMCwxLjNsLTUuMjUsNi4yNGMtLjIuMjQtLjQ4LjM2LS43Ny4zNloiLz4KICA8L2c+CiAgPGcgaWQ9IkxpbmVfMSIgZGF0YS1uYW1lPSJMaW5lIDEiPgogICAgPHBhdGggY2xhc3M9ImNscy0xIiBkPSJNMzIuNzUsMzQuNzNoLTEyYy0uNTUsMC0xLS40NS0xLTFzLjQ1LTEsMS0xaDEyYy41NSwwLDEsLjQ1LDEsMXMtLjQ1LDEtMSwxWiIvPgogIDwvZz4KPC9zdmc+";
284
285pub(crate) fn page_shell(body_html: &str) -> String {
289 format!(
290 r#"<!doctype html>
291<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>larust db</title><link rel="icon" href="{FAVICON_DATA_URI}" type="image/svg+xml">{STYLE}</head>
292<body>
293{body_html}
294</body></html>
295"#
296 )
297}
298
299fn topbar_html(csrf: &str) -> String {
302 let base = dashboard_path();
303 format!(
304 r#"<div class="topbar">
305 <form method="post" action="/{base}/logout">
306 <input type="hidden" name="{field}" value="{csrf}">
307 <button type="submit" class="link-button">Log out</button>
308 </form>
309</div>"#,
310 field = larust_http::csrf::FIELD_NAME,
311 csrf = html_escape(csrf),
312 )
313}
314
315fn sidebar_html(
325 base: &str,
326 csrf: &str,
327 active: Section,
328 tables: &[String],
329 active_table: Option<&str>,
330) -> String {
331 let tab = |section: Section, href: String, label: &str| {
332 let class = if section == active {
333 "nav-tab-v is-active"
334 } else {
335 "nav-tab-v"
336 };
337 format!(r#"<a class="{class}" href="{href}">{label}</a>"#)
338 };
339 let section_nav = format!(
340 r#"<nav class="nav-tabs-vertical">
341 {database}
342 {sql}
343 {kv}
344</nav>"#,
345 database = tab(Section::Database, format!("/{base}"), "Database"),
346 sql = tab(Section::Sql, format!("/{base}/sql"), "SQL"),
347 kv = tab(Section::Kv, format!("/{base}/kv"), "Key-Value"),
348 );
349
350 let database_extra = if active == Section::Database {
351 let table_items: String = tables
352 .iter()
353 .map(|table| {
354 let class = if Some(table.as_str()) == active_table {
355 "table-nav-item is-active"
356 } else {
357 "table-nav-item"
358 };
359 format!(
360 r#"<a class="{class}" href="/{base}/t/{path}">{name}</a>"#,
361 path = path_segment(table),
362 name = html_escape(table),
363 )
364 })
365 .collect();
366 format!(
367 r#"<div class="sidebar-section">
368 <div class="sidebar-label">Tables</div>
369 <nav class="table-nav-list">{table_items}</nav>
370</div>
371<div class="sidebar-section">
372 <a class="sidebar-link" href="/{base}/import">Import .sql</a>
373</div>
374<div class="sidebar-danger">
375 <form method="post" action="/{base}/migrate/fresh" onsubmit="return confirm('This drops every table and reapplies migrations from scratch. Continue?')">
376 <input type="hidden" name="{field}" value="{csrf}">
377 <button type="submit" class="button-ghost">Fresh migrate</button>
378 </form>
379</div>"#,
380 field = larust_http::csrf::FIELD_NAME,
381 csrf = html_escape(csrf),
382 )
383 } else {
384 String::new()
385 };
386
387 format!(
388 r#"{brand}
389{section_nav}
390{database_extra}"#,
391 brand = brand_html("brand-compact"),
392 )
393}
394
395pub(crate) fn page_frame(
400 csrf: &str,
401 active: Section,
402 tables: &[String],
403 active_table: Option<&str>,
404 main_html: &str,
405) -> String {
406 format!(
407 r#"<div class="wrap">
408 {topbar}
409 <div class="dashboard-layout">
410 <aside class="sidebar">
411 {sidebar}
412 </aside>
413 <main class="main-content">
414 {main_html}
415 </main>
416 </div>
417</div>"#,
418 topbar = topbar_html(csrf),
419 sidebar = sidebar_html(dashboard_path(), csrf, active, tables, active_table),
420 )
421}
422
423fn render_login_page(csrf: &str, error: Option<&str>) -> String {
424 let base = dashboard_path();
425 let error_html = error
426 .map(|message| format!(r#"<p class="error">{}</p>"#, html_escape(message)))
427 .unwrap_or_default();
428 page_shell(&format!(
429 r#"<div class="wrap wrap-narrow">
430 <div class="card card-centered">
431 {brand}
432 <p class="subtitle">Sign in to browse and edit</p>
433 {error_html}
434 <form method="post" action="/{base}/login">
435 <input type="password" name="password" placeholder="Dashboard password" autofocus class="login-input">
436 <input type="hidden" name="{field}" value="{csrf}">
437 <button type="submit" class="button button-block">Log in</button>
438 </form>
439 </div>
440</div>"#,
441 brand = brand_html("brand-centered"),
442 field = larust_http::csrf::FIELD_NAME,
443 csrf = html_escape(csrf),
444 ))
445}
446
447const STYLE: &str = r#"<style>
456:root {
457 --ink: #202124;
458 --muted: #6b6d73;
459 --paper: #fffdf9;
460 --canvas: #f4f0e8;
461 --line: #e4ddd2;
462 --brand: #f4513d;
463 --brand-dark: #cf3628;
464 --brand-shadow: color-mix(in srgb, var(--brand) 19%, transparent);
465 --danger: #8c3028;
466 --danger-bg: #ffebe7;
467 --danger-line: #f0c4bc;
468 color-scheme: light dark;
469}
470@media (prefers-color-scheme: dark) {
471 :root {
472 --ink: #f6f1e8;
473 --muted: #b8b1a8;
474 --paper: #272522;
475 --canvas: #181716;
476 --line: #45413b;
477 --brand: #ff735f;
478 --brand-dark: #ff8a79;
479 --danger: #ffb4a8;
480 --danger-bg: #3a2320;
481 --danger-line: #5c332c;
482 }
483}
484* { box-sizing: border-box; }
485body {
486 margin: 0;
487 min-height: 100vh;
488 color: var(--ink);
489 background: var(--canvas);
490 font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
491 line-height: 1.5;
492}
493.wrap { width: min(1200px, calc(100% - 40px)); margin: 48px auto; }
494.wrap-narrow { width: min(420px, calc(100% - 40px)); margin: 96px auto; }
495.dashboard-layout { display: flex; align-items: flex-start; gap: 28px; }
496.sidebar { width: 220px; flex-shrink: 0; }
497.main-content { flex: 1; min-width: 0; }
498.nav-tabs-vertical { display: flex; flex-direction: column; gap: 2px; margin: 20px 0; }
499.nav-tab-v {
500 padding: 8px 10px;
501 border-radius: 8px;
502 color: var(--muted);
503 font-weight: 650;
504 font-size: .9rem;
505}
506.nav-tab-v:hover { background: var(--canvas); color: var(--ink); }
507.nav-tab-v.is-active { color: var(--brand-dark); background: var(--brand-shadow); }
508.sidebar-section { margin-bottom: 16px; }
509.sidebar-label {
510 padding: 0 10px;
511 margin-bottom: 6px;
512 color: var(--muted);
513 font-size: .72rem;
514 font-weight: 750;
515 text-transform: uppercase;
516 letter-spacing: .05em;
517}
518.table-nav-list { display: flex; flex-direction: column; gap: 1px; max-height: 50vh; overflow-y: auto; }
519.table-nav-item {
520 padding: 7px 10px;
521 border-radius: 8px;
522 color: var(--ink);
523 font-size: .85rem;
524 font-family: ui-monospace, "SF Mono", Consolas, monospace;
525 overflow: hidden;
526 text-overflow: ellipsis;
527 white-space: nowrap;
528}
529.table-nav-item:hover { background: var(--canvas); }
530.table-nav-item.is-active { background: var(--brand-shadow); color: var(--brand-dark); font-weight: 650; }
531.sidebar-link {
532 display: block;
533 padding: 7px 10px;
534 color: var(--muted);
535 font-size: .85rem;
536 font-weight: 650;
537 border-radius: 8px;
538}
539.sidebar-link:hover { background: var(--canvas); color: var(--ink); }
540.sidebar-danger { padding-top: 16px; border-top: 1px solid var(--line); }
541.sidebar-danger .button-ghost { width: 100%; color: var(--danger); box-shadow: inset 0 0 0 1px var(--danger-line); }
542.sidebar-danger .button-ghost:hover { background: var(--danger-bg); }
543.card {
544 background: var(--paper);
545 border: 1px solid var(--line);
546 border-radius: 18px;
547 padding: 30px 32px;
548}
549.card-centered { text-align: center; padding: 40px 36px; }
550.card-centered form { text-align: left; }
551.brand { display: inline-flex; gap: 12px; align-items: center; }
552.brand-centered { display: flex; justify-content: center; gap: 16px; margin-bottom: 22px; }
553.brand-mark {
554 display: grid;
555 place-items: center;
556 flex-shrink: 0;
557 width: 34px;
558 height: 34px;
559 color: #fff;
560 background: var(--brand);
561 border-radius: 10px 10px 10px 3px;
562 font-family: ui-monospace, monospace;
563 font-size: 1rem;
564 font-weight: 700;
565}
566.brand-centered .brand-mark {
567 width: 60px;
568 height: 60px;
569 border-radius: 18px 18px 18px 6px;
570 font-size: 1.6rem;
571}
572.brand-name { font-size: 1.15rem; font-weight: 800; letter-spacing: -.03em; }
573.brand-name strong { color: var(--brand); font-weight: 800; }
574.brand-centered .brand-name { font-size: 1.6rem; }
575h1 { margin: 0 0 4px; font-size: 1.3rem; letter-spacing: -.01em; }
576h2 { margin: 0 0 16px; font-size: 1.05rem; letter-spacing: -.01em; }
577.subtitle { margin: 6px 0 0; color: var(--muted); font-size: .88rem; }
578.card-centered .subtitle { margin: 0 0 28px; font-size: .95rem; }
579.topbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 18px; gap: 16px; }
580.nav-tabs { display: flex; gap: 4px; margin-bottom: 20px; border-bottom: 1px solid var(--line); }
581.nav-tab {
582 padding: 9px 16px;
583 color: var(--muted);
584 font-weight: 650;
585 font-size: .9rem;
586 border-bottom: 2px solid transparent;
587 margin-bottom: -1px;
588}
589.nav-tab:hover { color: var(--ink); }
590.nav-tab.is-active { color: var(--brand-dark); border-bottom-color: var(--brand); }
591.breadcrumb { margin: 0 0 16px; color: var(--muted); font-size: .85rem; }
592.breadcrumb a { color: var(--brand-dark); font-weight: 650; }
593.breadcrumb a:hover { text-decoration: underline; }
594input[type="text"], input[type="password"], textarea {
595 width: 100%;
596 padding: 11px 14px;
597 border: 1px solid var(--line);
598 border-radius: 10px;
599 background: var(--paper);
600 color: var(--ink);
601 font: inherit;
602 outline: none;
603}
604textarea { font-family: ui-monospace, "SF Mono", Consolas, monospace; font-size: .88rem; min-height: 120px; resize: vertical; }
605input[type="text"]:focus, input[type="password"]:focus, textarea:focus {
606 border-color: var(--brand);
607 box-shadow: 0 0 0 4px var(--brand-shadow);
608}
609input[readonly] { background: var(--canvas); color: var(--muted); }
610.login-input { margin-bottom: 16px; }
611.button {
612 display: inline-flex;
613 align-items: center;
614 justify-content: center;
615 border: 0;
616 border-radius: 10px;
617 padding: 11px 18px;
618 background: var(--brand);
619 color: #fff;
620 font: inherit;
621 font-weight: 750;
622 cursor: pointer;
623 white-space: nowrap;
624 box-shadow: 0 6px 14px var(--brand-shadow);
625 transition: .15s;
626}
627.button:hover { background: var(--brand-dark); transform: translateY(-1px); }
628.button-block { width: 100%; }
629.button-secondary {
630 background: var(--paper);
631 color: var(--ink);
632 box-shadow: inset 0 0 0 1px var(--line);
633}
634.button-secondary:hover { background: var(--canvas); transform: none; }
635.button-ghost {
636 border: 0;
637 background: transparent;
638 color: var(--muted);
639 box-shadow: inset 0 0 0 1px var(--line);
640 border-radius: 8px;
641 padding: 6px 12px;
642 font: inherit;
643 font-size: .85rem;
644 font-weight: 650;
645 cursor: pointer;
646 transition: .15s;
647}
648.button-ghost:hover { background: var(--danger-bg); color: var(--danger); box-shadow: inset 0 0 0 1px var(--danger-line); }
649.link-button {
650 border: 0;
651 background: transparent;
652 color: var(--muted);
653 font: inherit;
654 font-size: .85rem;
655 font-weight: 650;
656 cursor: pointer;
657 padding: 2px;
658}
659.link-button:hover { color: var(--brand-dark); text-decoration: underline; }
660.set-row { display: flex; gap: 8px; margin-bottom: 24px; }
661.set-row input[type="text"]:first-child { flex: 0 0 200px; }
662.set-row input[type="text"]:nth-child(2) { flex: 1; }
663table { width: 100%; border-collapse: collapse; }
664th {
665 text-align: left;
666 padding: 8px 10px;
667 border-bottom: 1px solid var(--line);
668 color: var(--muted);
669 font-size: .72rem;
670 font-weight: 750;
671 text-transform: uppercase;
672 letter-spacing: .05em;
673 white-space: nowrap;
674}
675td { padding: 12px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
676tr:last-child td { border-bottom: none; }
677tr.is-clickable:hover { background: var(--canvas); }
678.key-cell { font-family: ui-monospace, "SF Mono", Consolas, monospace; font-weight: 650; white-space: nowrap; }
679.actions-cell { text-align: right; width: 1%; white-space: nowrap; }
680.count-cell { color: var(--muted); text-align: right; width: 1%; white-space: nowrap; }
681pre { margin: 0; font-family: ui-monospace, "SF Mono", Consolas, monospace; font-size: .82rem; white-space: pre-wrap; word-break: break-word; color: var(--muted); }
682.null-value { color: var(--muted); font-style: italic; }
683.empty-state { padding: 32px 8px; text-align: center; color: var(--muted); }
684.empty-state p { margin: 0 0 6px; }
685.empty-hint { font-size: .85rem; }
686.empty-hint code { background: var(--canvas); border-radius: 4px; padding: 2px 6px; font-family: ui-monospace, "SF Mono", Consolas, monospace; }
687.error {
688 margin: 0 0 16px;
689 padding: 10px 14px;
690 border-radius: 10px;
691 color: var(--danger);
692 background: var(--danger-bg);
693 font-size: .88rem;
694 font-weight: 650;
695}
696.form-field { display: grid; gap: 6px; margin-bottom: 16px; }
697.form-field label { font-size: .82rem; font-weight: 700; color: var(--muted); }
698.form-actions { display: flex; gap: 10px; margin-top: 20px; }
699.pagination { display: flex; justify-content: space-between; align-items: center; margin-top: 20px; font-size: .85rem; color: var(--muted); }
700.pagination a { color: var(--brand-dark); font-weight: 650; }
701.pagination a:hover { text-decoration: underline; }
702.toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; gap: 12px; }
703</style>"#;
704
705pub(crate) fn html_escape(input: &str) -> String {
709 input
710 .replace('&', "&")
711 .replace('<', "<")
712 .replace('>', ">")
713 .replace('"', """)
714 .replace('\'', "'")
715}
716
717pub(crate) fn internal<E: std::error::Error + Send + Sync + 'static>(error: E) -> AppError {
718 AppError::Internal(Box::new(error))
719}