max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
9 files changed,
+591 insertions,
-12 deletions
| @@ -155,6 +155,14 @@ | |||
| 155 | 155 | # here is measured rather than argued. Not load-bearing for any shipped route. | |
| 156 | 156 | # See wiki look-wave-2, tier G. | |
| 157 | 157 | quasi-router = { git = "https://makenot.work/git/max/quasi.git" } | |
| 158 | + | # The description vocabulary quasi's screen types are built from. Pinned here | |
| 159 | + | # rather than reached through quasi-router's re-export because a described | |
| 160 | + | # screen names FieldKind and Tone directly; it has to track what quasi-router | |
| 161 | + | # resolves or the two `layout::` paths are different crates. | |
| 162 | + | makeover-layout = "0.12.0" | |
| 163 | + | # For the request head the per-viewer state factory reads. axum re-exports it, | |
| 164 | + | # but the factory's signature is quasi-axum's and names `http::request::Parts`. | |
| 165 | + | http = "1.3.1" | |
| 158 | 166 | quasi-axum = { git = "https://makenot.work/git/max/quasi.git" } | |
| 159 | 167 | quasi-webview = { git = "https://makenot.work/git/max/quasi.git" } | |
| 160 | 168 |
| @@ -172,6 +172,27 @@ | |||
| 172 | 172 | .get::<Session>() | |
| 173 | 173 | .ok_or(AppError::Internal(anyhow::anyhow!("Session not found")))?; | |
| 174 | 174 | ||
| 175 | + | authenticate(session, state).await.map(AuthUser) | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | /// Resolve the signed-in user from a session, or refuse. | |
| 180 | + | /// | |
| 181 | + | /// The whole of [`AuthUser`]'s work, split out because the extractor is no | |
| 182 | + | /// longer the only caller: a described screen is served by quasi's adapter, | |
| 183 | + | /// which resolves the state a request is answered against from the request | |
| 184 | + | /// head, in async context, before the sync router runs. It holds a [`Session`] | |
| 185 | + | /// out of the same extensions the extractor reads and needs the same answer. | |
| 186 | + | /// | |
| 187 | + | /// Split rather than duplicated for the obvious reason and one less obvious | |
| 188 | + | /// one: the revocation rules here are the security-relevant part (a session | |
| 189 | + | /// with no tracking row is refused, a touch that fails invalidates), and a | |
| 190 | + | /// second copy is a second place for them to fall behind. | |
| 191 | + | pub async fn authenticate( | |
| 192 | + | session: &Session, | |
| 193 | + | state: &crate::AppState, | |
| 194 | + | ) -> Result<SessionUser, AppError> { | |
| 195 | + | { | |
| 175 | 196 | let user: SessionUser = session | |
| 176 | 197 | .get(USER_SESSION_KEY) | |
| 177 | 198 | .await | |
| @@ -248,7 +269,7 @@ | |||
| 248 | 269 | // (DB queries, error handlers, etc.) include it automatically. | |
| 249 | 270 | tracing::Span::current().record("user_id", tracing::field::display(&user.id)); | |
| 250 | 271 | ||
| 251 | - | Ok(AuthUser(user)) | |
| 272 | + | Ok(user) | |
| 252 | 273 | } | |
| 253 | 274 | } | |
| 254 | 275 |
| @@ -48,6 +48,7 @@ | |||
| 48 | 48 | pub mod openapi; | |
| 49 | 49 | pub mod payments; | |
| 50 | 50 | pub mod pricing; | |
| 51 | + | pub mod quasi; | |
| 51 | 52 | pub mod quasi_spike; | |
| 52 | 53 | pub mod rate_limit; | |
| 53 | 54 | pub mod routes; | |
| @@ -554,7 +555,10 @@ | |||
| 554 | 555 | .merge(build_routes()) | |
| 555 | 556 | .finalize(); | |
| 556 | 557 | let app = Router::new() | |
| 557 | - | .merge(page_routes(state.config.rate_limits)) | |
| 558 | + | .merge(page_routes( | |
| 559 | + | state.config.rate_limits, | |
| 560 | + | &state.config.quasi_screens, | |
| 561 | + | )) | |
| 558 | 562 | .merge(sso_routes()) | |
| 559 | 563 | .merge(csrf_routes) | |
| 560 | 564 | .merge(git_routes()) | |
| @@ -585,6 +589,17 @@ | |||
| 585 | 589 | .fallback(routes::custom_domain::custom_domain_fallback) | |
| 586 | 590 | .with_state(state.clone()); | |
| 587 | 591 | ||
| 592 | + | // The description layer, when a screen is switched on. Nested as a service | |
| 593 | + | // for the same reason the spike is: the adapter mounts as a fallback and | |
| 594 | + | // this server already has one. Mounted after `with_state` because the | |
| 595 | + | // adapter carries its own state, resolved per request, and takes none from | |
| 596 | + | // axum. The Askama route for a described screen is not registered (see | |
| 597 | + | // `dashboard_routes`), so nothing here overlaps. | |
| 598 | + | let app = match quasi::router(&state) { | |
| 599 | + | Some(described) => app.nest_service("/dashboard/tabs/ssh-keys", described), | |
| 600 | + | None => app, | |
| 601 | + | }; | |
| 602 | + | ||
| 588 | 603 | // There is no /metrics scrape endpoint. Prometheus and Grafana were retired | |
| 589 | 604 | // on 2026-07-21 and PoM is the monitoring story, so the endpoint had no | |
| 590 | 605 | // consumer left. The recorder itself stays: the admin metrics dashboard |
| @@ -71,7 +71,14 @@ | |||
| 71 | 71 | // The load runner drives thousands of requests from one IP, so the | |
| 72 | 72 | // production limiter would measure the limiter rather than the server. | |
| 73 | 73 | rate_limits: makenotwork::constants::RateLimits::relaxed(), | |
| 74 | - | quasi_screens: makenotwork::config::QuasiScreens::default(), | |
| 74 | + | // Read from the environment rather than defaulted off, because the | |
| 75 | + | // conversion's one open cost is measured by running this twice: once | |
| 76 | + | // with every screen on Askama and once with QUASI_SCREENS naming the | |
| 77 | + | // described one. A default here would silently measure the same side | |
| 78 | + | // twice. See wiki `mnw-server-conversion-plan`, S3. | |
| 79 | + | quasi_screens: makenotwork::config::QuasiScreens::parse( | |
| 80 | + | &std::env::var("QUASI_SCREENS").unwrap_or_default(), | |
| 81 | + | ), | |
| 75 | 82 | build: BuildConfig { | |
| 76 | 83 | trigger_token: None, | |
| 77 | 84 | host_linux: None, |
| @@ -399,7 +399,19 @@ | |||
| 399 | 399 | } | |
| 400 | 400 | sleep(think_time).await; | |
| 401 | 401 | ||
| 402 | - | let tabs = ["details", "payments", "projects", "creator", "promotions"]; | |
| 402 | + | // ssh-keys is the described screen (S3). It is in the mix rather than | |
| 403 | + | // measured alone because the question is what a described route does to the | |
| 404 | + | // rest of the server, not what it costs on an idle box: it holds a | |
| 405 | + | // blocking-pool thread for three database round trips while everything | |
| 406 | + | // else contends for the same pool. | |
| 407 | + | let tabs = [ | |
| 408 | + | "details", | |
| 409 | + | "payments", | |
| 410 | + | "projects", | |
| 411 | + | "creator", | |
| 412 | + | "promotions", | |
| 413 | + | "ssh-keys", | |
| 414 | + | ]; | |
| 403 | 415 | ||
| 404 | 416 | while Instant::now() < deadline { | |
| 405 | 417 | timed_get(&mut client, "/dashboard", "GET /dashboard", &metrics).await; | |
| @@ -407,7 +419,16 @@ | |||
| 407 | 419 | ||
| 408 | 420 | for tab in &tabs { | |
| 409 | 421 | let url = format!("/dashboard/tabs/{tab}"); | |
| 410 | - | timed_htmx_get(&mut client, &url, "HTMX /dashboard/tabs/{tab}", &metrics).await; | |
| 422 | + | // ssh-keys reports under its own label. The others share one on | |
| 423 | + | // purpose (they are one population), but the described screen is | |
| 424 | + | // the population being compared, and folding it into the rest | |
| 425 | + | // would average away exactly the number S3 is after. | |
| 426 | + | let label = if *tab == "ssh-keys" { | |
| 427 | + | "HTMX /dashboard/tabs/ssh-keys" | |
| 428 | + | } else { | |
| 429 | + | "HTMX /dashboard/tabs/{tab}" | |
| 430 | + | }; | |
| 431 | + | timed_htmx_get(&mut client, &url, label, &metrics).await; | |
| 411 | 432 | sleep(think_time).await; | |
| 412 | 433 | } | |
| 413 | 434 |
| @@ -19,11 +19,14 @@ | |||
| 19 | 19 | /// merged `email_actions`/`sandbox`/`feeds`/`blog` as bare `Router`s, and | |
| 20 | 20 | /// `email_actions`'s `POST /forgot-password` (plus `sandbox`'s `POST /sandbox`) | |
| 21 | 21 | /// skipped the envelope. `finalize()` drops the wrapper exactly once, here. | |
| 22 | - | pub fn page_routes(limits: crate::constants::RateLimits) -> Router<AppState> { | |
| 22 | + | pub fn page_routes( | |
| 23 | + | limits: crate::constants::RateLimits, | |
| 24 | + | screens: &crate::config::QuasiScreens, | |
| 25 | + | ) -> Router<AppState> { | |
| 23 | 26 | CsrfRouter::new() | |
| 24 | 27 | .merge(public::public_routes(limits)) | |
| 25 | 28 | .merge(sandbox::sandbox_routes(limits)) | |
| 26 | - | .merge(dashboard::dashboard_routes()) | |
| 29 | + | .merge(dashboard::dashboard_routes(screens)) | |
| 27 | 30 | .merge(email_actions::email_action_routes(limits)) | |
| 28 | 31 | .merge(feeds::feed_routes()) | |
| 29 | 32 | .merge(blog::blog_routes()) |
| @@ -48,7 +48,7 @@ | |||
| 48 | 48 | } | |
| 49 | 49 | ||
| 50 | 50 | /// Register dashboard page routes. | |
| 51 | - | pub(crate) fn dashboard_routes() -> CsrfRouter<AppState> { | |
| 51 | + | pub(crate) fn dashboard_routes(screens: &crate::config::QuasiScreens) -> CsrfRouter<AppState> { | |
| 52 | 52 | let read_rate_limit = crate::helpers::rate_limiter_ms( | |
| 53 | 53 | constants::DASHBOARD_READ_RATE_LIMIT_MS, | |
| 54 | 54 | constants::DASHBOARD_READ_RATE_LIMIT_BURST, | |
| @@ -79,11 +79,10 @@ | |||
| 79 | 79 | .route_get("/dashboard/tabs/synckit", get(tabs::dashboard_tab_synckit)) | |
| 80 | 80 | .route_get("/dashboard/tabs/forums", get(tabs::dashboard_tab_forums)) | |
| 81 | 81 | .route_get("/dashboard/tabs/media", get(tabs::dashboard_tab_media)) | |
| 82 | - | .route_get( | |
| 83 | - | "/dashboard/tabs/ssh-keys", | |
| 84 | - | get(tabs::dashboard_tab_ssh_keys), | |
| 85 | - | ) | |
| 86 | 82 | .route_get("/dashboard/tabs/support", get(tabs::dashboard_tab_support)) | |
| 83 | + | // The SSH-keys tab is registered below rather than here: when its | |
| 84 | + | // screen is switched on, `crate::quasi` serves this address instead and | |
| 85 | + | // axum panics on two routes claiming one path. | |
| 87 | 86 | .route_get( | |
| 88 | 87 | "/dashboard/tabs/contacts", | |
| 89 | 88 | get(tabs::dashboard_tab_contacts), | |
| @@ -158,6 +157,18 @@ | |||
| 158 | 157 | ) | |
| 159 | 158 | .route_layer(GovernorLayer::new(read_rate_limit)); | |
| 160 | 159 | ||
| 160 | + | // The one screen the description layer serves, when it is switched on. The | |
| 161 | + | // Askama handler stays registered and reachable in every other deployment, | |
| 162 | + | // which is what makes the conversion revert by editing an env var. | |
| 163 | + | let tab_routes = if screens.enabled(crate::quasi::ssh_keys::SCREEN) { | |
| 164 | + | tab_routes | |
| 165 | + | } else { | |
| 166 | + | tab_routes.route_get( | |
| 167 | + | "/dashboard/tabs/ssh-keys", | |
| 168 | + | get(tabs::dashboard_tab_ssh_keys), | |
| 169 | + | ) | |
| 170 | + | }; | |
| 171 | + | ||
| 161 | 172 | CsrfRouter::new() | |
| 162 | 173 | .merge(wizards::wizard_routes()) | |
| 163 | 174 | .route_get("/dashboard", get(main::dashboard)) |
| @@ -1,0 +1,123 @@ | |||
| 1 | + | //! The description layer: screens served through quasi rather than Askama. | |
| 2 | + | //! | |
| 3 | + | //! Wiki note `mnw-server-conversion-plan`, step S3 onward. `crate::shell` | |
| 4 | + | //! already put the document head under quasi's renderer (S1); this is where | |
| 5 | + | //! screens themselves start moving. One screen at a time, each behind | |
| 6 | + | //! [`QuasiScreens`](crate::config::QuasiScreens), so a conversion ships when | |
| 7 | + | //! its own test is green and reverts by editing an env var. | |
| 8 | + | //! | |
| 9 | + | //! # Why a per-request state and not a per-request handler argument | |
| 10 | + | //! | |
| 11 | + | //! `quasi_router::Handler<S> = fn(&S, Params)`. `S` is the app, and on every | |
| 12 | + | //! other host in the tree there is one app and one viewer for the life of the | |
| 13 | + | //! process. A server has one process and many viewers, and that is the only | |
| 14 | + | //! assumption it breaks, so the fix is that the adapter builds `S` per request | |
| 15 | + | //! rather than that the handler grows a parameter. `Adapter::per_viewer` is | |
| 16 | + | //! that: the factory runs in async context, where the session lookup already | |
| 17 | + | //! lives, and hands the sync router a [`Viewer`] with the answer already in it. | |
| 18 | + | //! | |
| 19 | + | //! # The cost this exists to measure | |
| 20 | + | //! | |
| 21 | + | //! The router is sync (quasi's decision 6, taken for hosts with no runtime), so | |
| 22 | + | //! quasi-axum dispatches on `spawn_blocking` and a handler reaching sqlx does it | |
| 23 | + | //! through `Handle::block_on`. Every described request therefore holds a | |
| 24 | + | //! blocking-pool thread for the length of its database round trips. That is | |
| 25 | + | //! fine on a desktop app over rusqlite and is an open question on the one host | |
| 26 | + | //! in the tree with many concurrent readers. It is not arguable, only | |
| 27 | + | //! measurable: see `tests/load` and the S3 numbers in the wiki note. | |
| 28 | + | ||
| 29 | + | use tokio::runtime::Handle; | |
| 30 | + | ||
| 31 | + | use crate::AppState; | |
| 32 | + | use crate::auth::SessionUser; | |
| 33 | + | ||
| 34 | + | pub mod ssh_keys; | |
| 35 | + | ||
| 36 | + | /// The state one request is answered against. | |
| 37 | + | /// | |
| 38 | + | /// Built per request by the adapter's factory, which is the whole of quasi's | |
| 39 | + | /// answer to a server: everything resolvable from the request head is loaded | |
| 40 | + | /// before the sync router runs, and a handler reads it off `&S` the way every | |
| 41 | + | /// other host's handler reads its app. | |
| 42 | + | pub struct Viewer { | |
| 43 | + | /// The long-lived application state. Cloning it clones handles, not data. | |
| 44 | + | pub app: AppState, | |
| 45 | + | /// Who is asking. Resolved and revocation-checked by | |
| 46 | + | /// [`crate::auth::authenticate`], the same path the extractor takes. | |
| 47 | + | pub user: SessionUser, | |
| 48 | + | /// The runtime the request arrived on, so a sync handler can reach the | |
| 49 | + | /// async database. Captured in the factory rather than read inside the | |
| 50 | + | /// handler: `Handle::current` works on a blocking thread today, and | |
| 51 | + | /// depending on that is depending on where quasi-axum happens to dispatch. | |
| 52 | + | pub runtime: Handle, | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | impl Viewer { | |
| 56 | + | /// Run a database future from inside a sync handler. | |
| 57 | + | /// | |
| 58 | + | /// The blocking hop, named in one place so the thing being measured is | |
| 59 | + | /// countable rather than spread across every handler. Every call holds this | |
| 60 | + | /// blocking thread until the query answers. | |
| 61 | + | pub fn block_on<F: Future>(&self, future: F) -> F::Output { | |
| 62 | + | self.runtime.block_on(future) | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | /// Build the state factory the adapter calls per request. | |
| 67 | + | /// | |
| 68 | + | /// Refuses with `Unauthorized` when there is no session to resolve, which the | |
| 69 | + | /// adapter turns into a bare status with no body. That is the right shape here | |
| 70 | + | /// and not a shortcut: a store that will not answer is not a signed-out reader, | |
| 71 | + | /// and rendering a sign-in notice would need the renderer that is built from | |
| 72 | + | /// the state that could not be resolved. | |
| 73 | + | fn viewer_factory( | |
| 74 | + | app: AppState, | |
| 75 | + | ) -> impl Fn(&http::request::Parts) -> quasi_axum::StateFuture<Viewer> + Send + Sync + 'static { | |
| 76 | + | move |parts| { | |
| 77 | + | let app = app.clone(); | |
| 78 | + | let runtime = Handle::current(); | |
| 79 | + | // Taken out of the head first, so the future owns what it needs. | |
| 80 | + | let session = parts.extensions.get::<tower_sessions::Session>().cloned(); | |
| 81 | + | Box::pin(async move { | |
| 82 | + | let Some(session) = session else { | |
| 83 | + | // The session layer runs in front of this. Its absence is a | |
| 84 | + | // wiring mistake rather than a signed-out reader. | |
| 85 | + | return Err(quasi_router::RouteError::internal("no session layer")); | |
| 86 | + | }; | |
| 87 | + | match crate::auth::authenticate(&session, &app).await { | |
| 88 | + | Ok(user) => Ok(Viewer { app, user, runtime }), | |
| 89 | + | Err(_) => Err(quasi_router::RouteError::denied("sign in to continue")), | |
| 90 | + | } | |
| 91 | + | }) | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | /// Mount every converted screen, or nothing. | |
| 96 | + | /// | |
| 97 | + | /// Returns `None` when no screen in this module is switched on, so the caller | |
| 98 | + | /// registers its Askama routes exactly as before and the adapter is not in the | |
| 99 | + | /// stack at all. A conversion is a startup-time choice: config is read once, | |
| 100 | + | /// and a per-request branch would pay for a switch that never moves. | |
| 101 | + | pub fn router(app: &AppState) -> Option<axum::Router> { | |
| 102 | + | let screens = &app.config.quasi_screens; | |
| 103 | + | if !screens.enabled(ssh_keys::SCREEN) { | |
| 104 | + | return None; | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | let quasi = quasi_router::Router::<Viewer>::new().get("/", ssh_keys::screen); | |
| 108 | + | Some( | |
| 109 | + | quasi_axum::Adapter::per_viewer(quasi, viewer_factory(app.clone()), |viewer, _, _| { | |
| 110 | + | ssh_keys::renderer(viewer) | |
| 111 | + | }) | |
| 112 | + | .into_router(), | |
| 113 | + | ) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | /// Whether the SSH-keys tab serves from the description layer. | |
| 117 | + | /// | |
| 118 | + | /// Read by the dashboard's route table, which mounts one or the other. Here | |
| 119 | + | /// rather than there so the switch and the screen it names stay together. | |
| 120 | + | #[must_use] | |
| 121 | + | pub fn ssh_keys_described(app: &AppState) -> bool { | |
| 122 | + | app.config.quasi_screens.enabled(ssh_keys::SCREEN) | |
| 123 | + | } |
| @@ -1,0 +1,370 @@ | |||
| 1 | + | //! The SSH-keys settings tab, described. | |
| 2 | + | //! | |
| 3 | + | //! The first authenticated screen through the description layer, chosen because | |
| 4 | + | //! it isolates what the conversion is actually asking: two lists, each with a | |
| 5 | + | //! destructive per-row action, two add forms and a picker, no bespoke markup | |
| 6 | + | //! worth keeping, and it is where the console-theme work already landed. | |
| 7 | + | //! | |
| 8 | + | //! Compare `routes::pages::dashboard::tabs::user::dashboard_tab_ssh_keys`, | |
| 9 | + | //! which answers the same address from Askama when the screen is switched off. | |
| 10 | + | //! | |
| 11 | + | //! # It does not render the same page, on purpose | |
| 12 | + | //! | |
| 13 | + | //! The Askama tab renders two empty divs that fetch their own contents on load: | |
| 14 | + | //! `#ssh-keys-list` and `#git-tokens-list` each carry `hx-trigger="load"`, so | |
| 15 | + | //! opening the tab costs three round trips and shows two "Loading..." lines on | |
| 16 | + | //! the way. A description has no word for "an empty region that fetches itself", | |
| 17 | + | //! and should not: the handler is already in the request, and a list it can read | |
| 18 | + | //! now is a list the reader should not wait for twice. | |
| 19 | + | //! | |
| 20 | + | //! So this renders both lists inline, in one response. That is a better screen | |
| 21 | + | //! and it is also why **the parity harness cannot cover this conversion** — the | |
| 22 | + | //! two renderings differ by design rather than by accident. See the S3 notes in | |
| 23 | + | //! wiki `mnw-server-conversion-plan`. | |
| 24 | + | //! | |
| 25 | + | //! It is also what makes the screen worth measuring. The Askama tab handler runs | |
| 26 | + | //! one query; this one runs three, which is what a described dashboard screen | |
| 27 | + | //! will typically look like, and therefore what the blocking-pool question is | |
| 28 | + | //! actually about. | |
| 29 | + | //! | |
| 30 | + | //! # Two vocabulary gaps this screen hit | |
| 31 | + | //! | |
| 32 | + | //! Both filed on quasicoherent rather than worked around silently. | |
| 33 | + | //! | |
| 34 | + | //! 1. **A table row cannot carry an action.** `Node::Table`'s rows are | |
| 35 | + | //! [`Cells`], which offer `activate` and nothing else, so a table whose rows | |
| 36 | + | //! each have a Remove button cannot be said. `Node::List` can say it, so both | |
| 37 | + | //! lists here are lists, and the column headers the templates had are gone. | |
| 38 | + | //! That is a real loss on the tokens list, which has four columns. | |
| 39 | + | //! 2. **No date field.** The token form's expiry was `<input type="date">` and | |
| 40 | + | //! is now `Text`. See [`add_token_form`]. | |
| 41 | + | ||
| 42 | + | use makeover_layout as layout; | |
| 43 | + | use quasi_router::screen::{Act, Choice, Field, Row}; | |
| 44 | + | use quasi_router::{Action, Node, Params, RegionKind, Response, RouteError, Slot}; | |
| 45 | + | use quasi_webview::{Shell, Webview}; | |
| 46 | + | ||
| 47 | + | use super::Viewer; | |
| 48 | + | use crate::db; | |
| 49 | + | use crate::theming::ThemeOption; | |
| 50 | + | ||
| 51 | + | /// The conversion switch's name for this screen. `QUASI_SCREENS=user_ssh_keys`. | |
| 52 | + | pub const SCREEN: &str = "user_ssh_keys"; | |
| 53 | + | ||
| 54 | + | /// The region the answer replaces: the settings pane the tab nav targets. | |
| 55 | + | /// | |
| 56 | + | /// The nav's own `hx-target` says the same thing. Naming it here is what lets | |
| 57 | + | /// the router say what it changed rather than leaving the client to infer it | |
| 58 | + | /// from which link was clicked, and the two agreeing is checked below. | |
| 59 | + | const REGION: &str = "settings-body"; | |
| 60 | + | ||
| 61 | + | /// One registered key, as the screen needs it. | |
| 62 | + | /// | |
| 63 | + | /// The description is built from these rather than from `db::DbSshKey` so the | |
| 64 | + | /// shape of a screen can be tested without a database, which is most of what | |
| 65 | + | /// makes a described screen cheaper to hold than a template. | |
| 66 | + | pub struct KeyView { | |
| 67 | + | id: String, | |
| 68 | + | fingerprint: String, | |
| 69 | + | label: String, | |
| 70 | + | added: String, | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | /// One issued token, as the screen needs it. | |
| 74 | + | pub struct TokenView { | |
| 75 | + | id: String, | |
| 76 | + | name: String, | |
| 77 | + | scope: &'static str, | |
| 78 | + | expires: String, | |
| 79 | + | last_used: String, | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | /// The tab. | |
| 83 | + | pub fn screen(viewer: &Viewer, _params: Params) -> Result<Response, RouteError> { | |
| 84 | + | let user_id = viewer.user.id; | |
| 85 | + | ||
| 86 | + | // Three round trips, each holding this blocking thread. The thing S3 | |
| 87 | + | // exists to measure; see the module header on `super`. | |
| 88 | + | let keys = viewer | |
| 89 | + | .block_on(db::ssh_keys::list_keys_by_user(&viewer.app.db, user_id)) | |
| 90 | + | .map_err(|_| RouteError::internal("your keys could not be read"))?; | |
| 91 | + | let tokens = viewer | |
| 92 | + | .block_on(db::git_access_tokens::list_by_user(&viewer.app.db, user_id)) | |
| 93 | + | .map_err(|_| RouteError::internal("your tokens could not be read"))?; | |
| 94 | + | let profile = viewer | |
| 95 | + | .block_on(db::users::get_user_by_id(&viewer.app.db, user_id)) | |
| 96 | + | .map_err(|_| RouteError::internal("your account could not be read"))? | |
| 97 | + | .ok_or_else(|| RouteError::not_found("that account is gone"))?; | |
| 98 | + | ||
| 99 | + | let keys: Vec<KeyView> = keys | |
| 100 | + | .iter() | |
| 101 | + | .map(|k| KeyView { | |
| 102 | + | id: k.id.to_string(), | |
| 103 | + | fingerprint: k.fingerprint.clone(), | |
| 104 | + | label: k.label.clone(), | |
| 105 | + | added: k.created_at.format("%b %d, %Y").to_string(), | |
| 106 | + | }) | |
| 107 | + | .collect(); | |
| 108 | + | let tokens: Vec<TokenView> = tokens | |
| 109 | + | .iter() | |
| 110 | + | .map(|t| TokenView { | |
| 111 | + | id: t.id.to_string(), | |
| 112 | + | name: t.name.clone(), | |
| 113 | + | scope: if t.can_push { "Read + push" } else { "Read" }, | |
| 114 | + | expires: never_or(t.expires_at), | |
| 115 | + | last_used: never_or(t.last_used_at), | |
| 116 | + | }) | |
| 117 | + | .collect(); | |
| 118 | + | let themes = crate::theming::console_theme_options(profile.console_theme.as_deref()); | |
| 119 | + | ||
| 120 | + | Ok(Response::fragment( | |
| 121 | + | REGION, | |
| 122 | + | pane(&viewer.user.username.to_string(), &keys, &tokens, &themes), | |
| 123 | + | )) | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | /// A date, or the word for not having one. | |
| 127 | + | fn never_or(at: Option<chrono::DateTime<chrono::Utc>>) -> String { | |
| 128 | + | at.map_or_else(|| "Never".to_owned(), |d| d.format("%b %d, %Y").to_string()) | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | /// Everything inside the settings pane. | |
| 132 | + | /// | |
| 133 | + | /// Split from the handler so a test can build it without a database, which is | |
| 134 | + | /// the same split `quasi_spike` used and the reason the description layer is | |
| 135 | + | /// testable at all: the screen is a value. | |
| 136 | + | fn pane(username: &str, keys: &[KeyView], tokens: &[TokenView], themes: &[ThemeOption]) -> Node { | |
| 137 | + | Node::Region( | |
| 138 | + | Slot::new(REGION, RegionKind::Pane) | |
| 139 | + | .with(Node::section("SSH Keys")) | |
| 140 | + | .with(Node::text(format!( | |
| 141 | + | "Manage SSH keys for git clone and push access. \ | |
| 142 | + | Clone URL: git@makenot.work:{username}/{{repo}}.git" | |
| 143 | + | ))) | |
| 144 | + | .with(keys_list(keys)) | |
| 145 | + | .with(add_key_form()) | |
| 146 | + | .with(Node::section("Console theme")) | |
| 147 | + | .with(Node::text( | |
| 148 | + | "Color palette for your terminal dashboard over ssh makenot.work.", | |
| 149 | + | )) | |
| 150 | + | .with(theme_form(themes)) | |
| 151 | + | .with(Node::section("Access Tokens (HTTPS)")) | |
| 152 | + | .with(Node::text(format!( | |
| 153 | + | "Personal access tokens for git over HTTPS. Use a token as the password. \ | |
| 154 | + | Clone URL: https://<token>@makenot.work/{username}/{{repo}}.git" | |
| 155 | + | ))) | |
| 156 | + | .with(tokens_list(tokens)) | |
| 157 | + | .with(add_token_form()), | |
| 158 | + | ) | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | /// The registered keys, or the sentence saying there are none. | |
| 162 | + | fn keys_list(keys: &[KeyView]) -> Node { | |
| 163 | + | if keys.is_empty() { | |
| 164 | + | return Node::empty("No SSH keys registered."); | |
| 165 | + | } | |
| 166 | + | Node::list(keys.iter().map(|key| { | |
| 167 | + | Row::new(key.fingerprint.clone()) | |
| 168 | + | .secondary(key.label.clone()) | |
| 169 | + | .meta(format!("Added {}", key.added)) | |
| 170 | + | .act( | |
| 171 | + | Act::new( | |
| 172 | + | "Remove", | |
| 173 | + | Action::post(format!("/api/users/me/ssh-keys/{}/delete", key.id)), | |
| 174 | + | ) | |
| 175 | + | // The template asked with hx-confirm. Said here, a terminal host | |
| 176 | + | // asks in its own way and no host can forget to ask. | |
| 177 | + | .confirm("Remove this SSH key?") | |
| 178 | + | .tone(layout::Tone::Danger), | |
| 179 | + | ) | |
| 180 | + | })) | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | /// The add-a-key form. | |
| 184 | + | fn add_key_form() -> Node { | |
| 185 | + | Node::Form { | |
| 186 | + | action: Action::post("/api/users/me/ssh-keys"), | |
| 187 | + | submit: "Add SSH Key".into(), | |
| 188 | + | fields: vec![ | |
| 189 | + | Field::new(layout::FieldKind::Textarea, "public_key", "Public Key") | |
| 190 | + | .required() | |
| 191 | + | .hint( | |
| 192 | + | "Paste the contents of your ~/.ssh/id_ed25519.pub or similar public key file", | |
| 193 | + | ), | |
| 194 | + | Field::new(layout::FieldKind::Text, "label", "Label"), | |
| 195 | + | ], | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | /// The console-theme picker. | |
| 200 | + | fn theme_form(themes: &[ThemeOption]) -> Node { | |
| 201 | + | let options: Vec<Choice> = themes | |
| 202 | + | .iter() | |
| 203 | + | .map(|t| Choice::new(t.id.clone(), t.name.clone())) | |
| 204 | + | .collect(); | |
| 205 | + | let mut field = Field::select("theme_id", "Console theme", options).hint( | |
| 206 | + | "Following the terminal picks a light or dark palette from what your terminal reports. \ | |
| 207 | + | Separate from your profile theme, which is what visitors see.", | |
| 208 | + | ); | |
| 209 | + | if let Some(chosen) = themes.iter().find(|t| t.selected) { | |
| 210 | + | field = field.value(chosen.id.clone()); | |
| 211 | + | } | |
| 212 | + | Node::Form { | |
| 213 | + | action: Action::post("/api/users/me/console-theme"), | |
| 214 | + | submit: "Save Theme".into(), | |
| 215 | + | fields: vec![field], | |
| 216 | + | } | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | /// The issued tokens, or the sentence saying there are none. | |
| 220 | + | fn tokens_list(tokens: &[TokenView]) -> Node { | |
| 221 | + | if tokens.is_empty() { | |
| 222 | + | return Node::empty("No access tokens."); | |
| 223 | + | } | |
| 224 | + | Node::list(tokens.iter().map(|token| { | |
| 225 | + | Row::new(token.name.clone()) | |
| 226 | + | .secondary(token.scope) | |
| 227 | + | .meta(format!( | |
| 228 | + | "Expires {} - last used {}", | |
| 229 | + | token.expires, token.last_used | |
| 230 | + | )) | |
| 231 | + | .act( | |
| 232 | + | Act::new( | |
| 233 | + | "Revoke", | |
| 234 | + | Action::post(format!("/api/users/me/git-tokens/{}/delete", token.id)), | |
| 235 | + | ) | |
| 236 | + | .confirm("Revoke this token?") | |
| 237 | + | .tone(layout::Tone::Danger), | |
| 238 | + | ) | |
| 239 | + | })) | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | /// The mint-a-token form. | |
| 243 | + | /// | |
| 244 | + | /// `expires_on` is a `Text` field and the Askama form spelled it | |
| 245 | + | /// `<input type="date">`. That is a real regression in the browser (no native | |
| 246 | + | /// picker, no platform validation) and it is a vocabulary gap rather than a | |
| 247 | + | /// choice: `layout::FieldKind` has `Email`, `Url` and `Tel`, each admitted for | |
| 248 | + | /// exactly the reason a date qualifies, and `Date` is the one common input type | |
| 249 | + | /// missing. Filed on quasicoherent; when it lands this becomes one word. | |
| 250 | + | fn add_token_form() -> Node { | |
| 251 | + | Node::Form { | |
| 252 | + | action: Action::post("/api/users/me/git-tokens"), | |
| 253 | + | submit: "Create Token".into(), | |
| 254 | + | fields: vec![ | |
| 255 | + | Field::new(layout::FieldKind::Text, "name", "Name").required(), | |
| 256 | + | Field::new(layout::FieldKind::Text, "expires_on", "Expires (optional)") | |
| 257 | + | .hint("YYYY-MM-DD. Leave blank for a token that does not expire."), | |
| 258 | + | Field::new( | |
| 259 | + | layout::FieldKind::Checkbox, | |
| 260 | + | "can_push", | |
| 261 | + | "Allow push (write access)", | |
| 262 | + | ), | |
| 263 | + | ], | |
| 264 | + | } | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | /// The renderer this screen is drawn with. | |
| 268 | + | /// | |
| 269 | + | /// Per request because `Adapter::per_viewer` builds one per request, and this | |
| 270 | + | /// screen has nothing viewer-specific to say to it yet. It will when S2 puts the | |
| 271 | + | /// site chrome here. | |
| 272 | + | pub fn renderer(_viewer: &Viewer) -> Webview { | |
| 273 | + | // The fragment path never emits a document, so the shell's asset paths do | |
| 274 | + | // not arise here. It is still the one the rest of the site uses, so a screen | |
| 275 | + | // that later answers as a whole page cannot disagree with `crate::shell`. | |
| 276 | + | Webview::new().with_shell(Shell::under("/static").layered(["base", "components", "responsive"])) | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | #[cfg(test)] | |
| 280 | + | mod tests { | |
| 281 | + | use super::*; | |
| 282 | + | use quasi_axum::Render; | |
| 283 | + | ||
| 284 | + | fn key(id: &str, fingerprint: &str) -> KeyView { | |
| 285 | + | KeyView { | |
| 286 | + | id: id.into(), | |
| 287 | + | fingerprint: fingerprint.into(), | |
| 288 | + | label: "laptop".into(), | |
| 289 | + | added: "Aug 10, 2026".into(), | |
| 290 | + | } | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | fn render(node: &Node) -> String { | |
| 294 | + | Webview::new().fragment(node) | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | #[test] | |
| 298 | + | fn the_region_matches_what_the_tab_nav_targets() { | |
| 299 | + | // The router says what it changed, through HX-Retarget. If this and the | |
| 300 | + | // template's hx-target ever disagree the tab swaps into nothing, and | |
| 301 | + | // that failure is invisible to every other test. | |
| 302 | + | let nav = include_str!("../../templates/partials/tabs/user_settings.html"); | |
| 303 | + | assert!( | |
| 304 | + | nav.contains(&format!("hx-target=\"#{REGION}\"")), | |
| 305 | + | "the settings nav targets #{REGION}" | |
| 306 | + | ); | |
| 307 | + | assert!(nav.contains("hx-get=\"/dashboard/tabs/ssh-keys\"")); | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | #[test] | |
| 311 | + | fn an_empty_account_says_so_rather_than_showing_two_empty_lists() { | |
| 312 | + | let html = render(&pane("max", &[], &[], &[])); | |
| 313 | + | assert!(html.contains("No SSH keys registered.")); | |
| 314 | + | assert!(html.contains("No access tokens.")); | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | #[test] | |
| 318 | + | fn the_clone_urls_carry_the_viewers_own_name_and_cannot_smuggle_markup() { | |
| 319 | + | let html = render(&pane("max", &[], &[], &[])); | |
| 320 | + | assert!(html.contains("git@makenot.work:max/{repo}.git")); | |
| 321 | + | ||
| 322 | + | // A username reaches this from the session, and the session from a | |
| 323 | + | // signup form. The description layer escapes every string it is handed; | |
| 324 | + | // this is the check that the format! above did not route around it. | |
| 325 | + | let hostile = render(&pane("<script>x()</script>", &[], &[], &[])); | |
| 326 | + | assert!(!hostile.contains("<script>x()")); | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | #[test] | |
| 330 | + | fn removing_a_key_asks_first_and_every_key_asks_about_itself() { | |
| 331 | + | let html = render(&keys_list(&[ | |
| 332 | + | key("k1", "SHA256:aaa"), | |
| 333 | + | key("k2", "SHA256:bbb"), | |
| 334 | + | ])); | |
| 335 | + | ||
| 336 | + | assert!(html.contains("SHA256:aaa") && html.contains("SHA256:bbb")); | |
| 337 | + | assert_eq!(html.matches("hx-confirm").count(), 2, "both ask: {html}"); | |
| 338 | + | // The addresses are per key rather than one shared endpoint, which is | |
| 339 | + | // the mistake a loop over rows makes when the id is read outside it. | |
| 340 | + | assert!(html.contains("/api/users/me/ssh-keys/k1/delete")); | |
| 341 | + | assert!(html.contains("/api/users/me/ssh-keys/k2/delete")); | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | #[test] | |
| 345 | + | fn the_theme_picker_offers_every_theme_and_marks_the_chosen_one() { | |
| 346 | + | let themes = crate::theming::console_theme_options(Some("nord")); | |
| 347 | + | let html = render(&theme_form(&themes)); | |
| 348 | + | ||
| 349 | + | assert!(html.contains("nord")); | |
| 350 | + | assert_eq!( | |
| 351 | + | html.matches("selected").count(), | |
| 352 | + | 1, | |
| 353 | + | "exactly one theme is current: {html}" | |
| 354 | + | ); | |
| 355 | + | } | |
| 356 | + | ||
| 357 | + | #[test] | |
| 358 | + | fn the_forms_post_to_the_addresses_the_api_actually_answers() { | |
| 359 | + | // The conversion's real risk: a described form that posts somewhere the | |
| 360 | + | // server does not serve fails at runtime and nowhere else. | |
| 361 | + | let html = render(&pane("max", &[], &[], &[])); | |
| 362 | + | for address in [ | |
| 363 | + | "/api/users/me/ssh-keys", | |
| 364 | + | "/api/users/me/console-theme", | |
| 365 | + | "/api/users/me/git-tokens", | |
| 366 | + | ] { | |
| 367 | + | assert!(html.contains(address), "{address} is posted to: {html}"); | |
| 368 | + | } | |
| 369 | + | } | |
| 370 | + | } |