crates/gunnar-ui-server/src/reading.rs
//! **What does this session get?** — asked once, answered in one place.
//!
//! ```text
//! request ─▶ Audience::Anonymous ─────────────▶ Offer { rendered, no pdf, not remembered }
//! └▶ Audience::Person("alice") ──┬──▶ Offer { what they chose, pdf, remembered }
//! └──▶ the PREFS table, keyed by them
//! ```
//!
//! # Why a type and not an `if caller.is_some()`
//!
//! Because there is going to be more of this. Today the fork is three facts —
//! which view of a markdown file opens first, whether that choice is remembered,
//! and whether the PDF button exists — and each of them was a candidate for its
//! own `if` in its own handler, spelled slightly differently, drifting as the
//! list grows. A console whose answer to "what may a stranger do" is scattered
//! across a dozen handlers is a console where the answer is *unknown*, and the
//! first time anybody notices is when one of the dozen says yes.
//!
//! So [`Audience`] names WHO is asking and [`Offer`] is the whole of WHAT they
//! get. A fourth difference is a field on `Offer` and a line in
//! [`Audience::offer`]; nothing downstream grows a branch.
//!
//! It is **not** a security boundary and must never be mistaken for one. The
//! boundary is two walls that were already there: `auth::middleware` decides
//! which paths a request with no session may reach at all, and gunnar decides
//! what the grantless guest principal may see. This decides what the console
//! *offers* — a button, a default, a remembered choice — on the far side of
//! both. `Offer::pdf` being false is why the button is absent; the PDF route
//! being off `is_anonymous_read` is why asking for it anyway does not work.
//!
//! # Where a preference lives
//!
//! In the one redb state store, in its own table ([`crate::state`]'s `PREFS`),
//! under the session's user name — the same shape `reviewer::store_key` files a
//! person's signing key under. One setting per person, not per file: "I read
//! markdown as source" is a sentence about a reader, and a per-file memory would
//! mean a reader who chose once and then met a hundred files that had not heard.
use serde::{Deserialize, Serialize};
use axum::extract::State;
use crate::app::AppState;
use crate::state::StateStore;
/// Which view of a markdown file opens first.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum View {
/// The typeset page — what the README panel shows, and the default for
/// everybody who has not said otherwise.
#[default]
Rendered,
/// The file as it was written, highlighted, with line numbers and anchors.
Source,
}
impl View {
/// The word this view is stored and sent as.
pub fn name(self) -> &'static str {
match self {
View::Rendered => "rendered",
View::Source => "source",
}
}
/// The view a word names, or nothing.
///
/// An unknown word is `None` and the caller uses the default. A stored
/// value from a later version of this console must not stop a page from
/// drawing.
pub fn parse(word: &str) -> Option<View> {
match word {
"rendered" => Some(View::Rendered),
"source" => Some(View::Source),
_ => None,
}
}
}
/// Who is asking.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Audience {
/// Nobody is logged in. There is no one to remember anything for, and that
/// is the whole of the difference — not a lesser reader, an unnamed one.
Anonymous,
/// A session the console can name: a passkey holder, the break-glass admin
/// token, or the open console's single developer.
Person(String),
}
/// **Everything the fork decides**, in one value.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct Offer {
/// Which view of a markdown file opens first.
pub markdown: View,
/// True when that choice was read back from the store rather than being the
/// default. The console says "remembered" next to the toggle only when it
/// is true, because a promise to remember that is not kept is worse than no
/// promise.
pub remembered: bool,
/// True when this session may ask for the PDF, and therefore when the
/// button exists.
pub pdf: bool,
}
/// The setting name, which is the second half of a preference key.
const MARKDOWN: &str = "markdown";
impl Audience {
/// The person's name, when there is one.
pub fn person(&self) -> Option<&str> {
match self {
Audience::Anonymous => None,
Audience::Person(who) => Some(who),
}
}
/// **What this session gets.**
///
/// A store that cannot be read is an unset preference: the reader gets the
/// default and the page draws. Nothing here is worth failing a request over
/// — the worst case is a person seeing the other view and pressing one
/// button.
pub fn offer(&self, store: Option<&StateStore>) -> Offer {
let Some(who) = self.person() else {
// No name, nothing to look up, and no PDF: see the module docs for
// which of those is the security decision (none of them).
return Offer {
markdown: View::Rendered,
remembered: false,
pdf: false,
};
};
let remembered = store
.and_then(|store| store.pref_get(&key(who, MARKDOWN)).ok().flatten())
.and_then(|bytes| String::from_utf8(bytes).ok())
.and_then(|word| View::parse(&word));
Offer {
markdown: remembered.unwrap_or_default(),
remembered: remembered.is_some(),
pdf: true,
}
}
/// Remember how this person reads markdown.
///
/// # Errors
///
/// The store-level write error, so the handler can say the choice was not
/// kept rather than claiming it was. An anonymous caller is not an error
/// here and cannot reach it: the route is behind a session, and this
/// returns `Ok(())` having written nothing if it ever were.
pub fn remember(&self, store: Option<&StateStore>, view: View) -> Result<(), String> {
let (Some(who), Some(store)) = (self.person(), store) else {
return Ok(());
};
store
.pref_put(&key(who, MARKDOWN), view.name().as_bytes())
.map_err(|e| e.to_string())
}
}
/// One person's setting, as a key in the store.
///
/// `\u{1f}` separates, as it does in the render cache's keys and the reviewer
/// key store's, so no user name can spell another user's setting.
fn key(who: &str, setting: &str) -> String {
format!("{who}\u{1f}{setting}")
}
/// **The console's small persistent state, from a handler's chair.**
///
/// `App` holds exactly one `StateStore` and holds it *behind* the subsystems
/// that use it — the passkeys behind `webauthn`, the reviewer keys behind
/// `reviewers`, the rendered pages behind `readmes` — rather than as a field of
/// its own. Reaching it through the render cache is the compromise, and it is
/// written down here, once, rather than repeated at each call site: what matters
/// for the promise made in `state.rs` is that this is the SAME database, in its
/// own table, and not a second one.
pub fn store(state: &AppState) -> Option<&StateStore> {
state.readmes.store()
}
impl axum::extract::FromRequestParts<AppState> for Audience {
type Rejection = std::convert::Infallible;
async fn from_request_parts(
parts: &mut http::request::Parts,
state: &AppState,
) -> Result<Audience, std::convert::Infallible> {
// The middleware's own decision, read back — the same input
// `api::Reader` takes, and for the same reason: a `Caller` extension
// exists when and only when a session or the admin token was verified.
if let Some(caller) = parts.extensions.get::<crate::auth::middleware::Caller>() {
return Ok(Audience::Person(caller.name().to_owned()));
}
// The open-console posture (`trunk serve` on loopback, no passkey and
// no admin token) has no `Caller` and is not anonymous: it is one
// developer, who gets everything. Told apart by asking the state rather
// than the request, because it is a fact about how the process started.
if !state.passkey_enabled() && state.admin_token.is_none() {
return Ok(Audience::Person(crate::forges::OPEN_CONSOLE.to_owned()));
}
Ok(Audience::Anonymous)
}
}
/// What the console asks for when it opens a markdown file: one call, one
/// answer, the whole fork.
///
/// `PUT` takes the same shape back with only `markdown` filled in.
#[derive(Debug, Clone, Copy, Deserialize)]
pub struct Choice {
pub markdown: View,
}
/// **`GET /api/account/reading`** — what this session gets.
///
/// Under `/api/account/` deliberately, and not because a reading preference is
/// a credential: that prefix is the one mutating surface `role_floor` puts at
/// `Viewer`, for the reason `api::account` gives — every handler under it acts
/// on the name in the SESSION, which no path segment, query parameter or body
/// field can change, so there is nothing to escalate to. This handler is
/// exactly that shape. A tenant who has just registered a passkey is a viewer,
/// and a viewer who could not choose how they read markdown would be looking at
/// a button that answers 403.
pub async fn read(
audience: Audience,
State(state): State<AppState>,
) -> crate::api::ApiResult<Offer> {
Ok(axum::Json(audience.offer(store(&state))))
}
/// **`PUT /api/account/reading`** — remember it.
///
/// Refused for a session the console cannot name. Unreachable through the
/// middleware, which lets no anonymous request under `/api/account/`; stated
/// here anyway, because "unreachable" is a claim about another file and the
/// alternative to stating it is a write keyed by nobody.
pub async fn set(
audience: Audience,
State(state): State<AppState>,
axum::Json(choice): axum::Json<Choice>,
) -> crate::api::ApiResult<Offer> {
if audience.person().is_none() {
return Err(crate::api::ApiError(crate::grpc::Fail {
code: "permissiondenied".to_owned(),
message: "this console remembers how you read markdown for people it can name; the \
choice lasts the page for everybody else"
.to_owned(),
}));
}
audience
.remember(store(&state), choice.markdown)
.map_err(|why| {
crate::api::ApiError(crate::grpc::Fail {
code: "internal".to_owned(),
message: format!("the choice could not be written down: {why}"),
})
})?;
Ok(axum::Json(audience.offer(store(&state))))
}
#[cfg(test)]
mod tests {
use super::*;
fn store() -> (StateStore, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("a temp dir");
let store = StateStore::open(dir.path().join("state.redb")).expect("a store");
(store, dir)
}
/// ★ **A visitor with no session gets the typeset page and no PDF**, and
/// nothing is written down about them.
///
/// Red-first, and it is the feature's first sentence: before this the file
/// view had one answer for everybody — the raw source — and
/// `.nornir/gunnar-quickstart.md` on gunnar.rs opened as markdown nobody
/// asked to read as markdown.
#[test]
fn a_stranger_gets_the_rendered_view_and_no_pdf() {
let (store, _dir) = store();
let offer = Audience::Anonymous.offer(Some(&store));
assert_eq!(offer.markdown, View::Rendered);
assert!(!offer.pdf, "the PDF button must not be offered anonymously");
assert!(!offer.remembered);
// And asking to remember writes nothing: there is nobody to key it by.
Audience::Anonymous
.remember(Some(&store), View::Source)
.expect("it is not an error");
assert_eq!(
Audience::Anonymous.offer(Some(&store)).markdown,
View::Rendered,
"an anonymous choice must not become everybody's"
);
}
/// ★ **A person's choice is remembered, and it is theirs.**
///
/// Two people, because a preference keyed by anything other than the
/// session's user — a cookie, a repository, "the last one set" — is a
/// setting that one reader changes for another. The second assertion is the
/// one that would catch it.
#[test]
fn a_choice_is_remembered_per_person() {
let (store, _dir) = store();
let alice = Audience::Person("alice".to_owned());
let vera = Audience::Person("viewer-vera".to_owned());
// Nobody has chosen anything yet: the default, and it says so.
assert_eq!(
alice.offer(Some(&store)),
Offer {
markdown: View::Rendered,
remembered: false,
pdf: true,
}
);
alice
.remember(Some(&store), View::Source)
.expect("it is written");
assert_eq!(
alice.offer(Some(&store)),
Offer {
markdown: View::Source,
remembered: true,
pdf: true,
}
);
assert_eq!(
vera.offer(Some(&store)).markdown,
View::Rendered,
"one person's choice changed another's"
);
// And it can be changed back, rather than being a one-way door.
alice
.remember(Some(&store), View::Rendered)
.expect("it is written");
assert_eq!(alice.offer(Some(&store)).markdown, View::Rendered);
}
/// A console with no store at all — the posture half the unit tests build —
/// still answers, and still offers the PDF to a session.
#[test]
fn without_a_store_everybody_gets_the_default_and_nothing_fails() {
let person = Audience::Person("alice".to_owned());
person.remember(None, View::Source).expect("not an error");
assert_eq!(
person.offer(None),
Offer {
markdown: View::Rendered,
remembered: false,
pdf: true,
}
);
}
/// A word this build does not know is the default, not a broken page.
#[test]
fn an_unknown_stored_word_reads_as_the_default() {
let (store, _dir) = store();
store
.pref_put(&key("alice", MARKDOWN), b"holographic")
.expect("write");
let offer = Audience::Person("alice".to_owned()).offer(Some(&store));
assert_eq!(offer.markdown, View::Rendered);
assert!(!offer.remembered, "an unreadable setting is not a choice");
assert_eq!(View::parse("source"), Some(View::Source));
assert_eq!(View::parse("Source"), None);
}
/// The key is the person and the setting, in that order, separated by the
/// character neither can contain — so a second setting is a second key and
/// never a migration of the first.
#[test]
fn a_setting_is_filed_under_the_person_and_the_setting_name() {
assert_eq!(key("alice", MARKDOWN), "alice\u{1f}markdown");
assert_ne!(key("alice", MARKDOWN), key("alice", "theme"));
}
}