Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

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"));
    }
}