Skip to main content

didbot_serve/
home.rs

1//! `GET /` on the server's own hostname: a page naming this server, its
2//! zones and who stands behind it.
3//!
4//! The server's own hostname is its primary zone, the host its service DID
5//! names. An account's hostname sits one label under a zone and answers `/`
6//! exactly as any path nothing serves, so this page never speaks for an
7//! account.
8//!
9//! Rendered here, with no script: every value on it is one this process
10//! already holds, the operator's handle included (see
11//! [`crate::OperatorHandle`]), and the page is the same document with or without
12//! JavaScript. It borrows the operations dashboard's stylesheet rather than
13//! carrying its own.
14
15use axum::extract::State;
16use axum::http::{HeaderMap, Uri};
17use axum::response::Response;
18use axum::routing::get;
19use axum::Router;
20use didbot_pds::ServerState;
21use html_escape::encode_safe;
22
23use crate::routes::{host_without_port, request_authority, unknown_route, AppState};
24
25/// Builds the homepage route, to be merged into the main router.
26///
27/// Any other method on `/` falls back to the same `404` a path nothing
28/// serves answers with, as it did before this route existed.
29pub fn routes() -> Router<AppState> {
30    Router::new().route("/", get(home).fallback(elsewhere))
31}
32
33/// `GET /`
34async fn home(State(state): State<AppState>, uri: Uri, headers: HeaderMap) -> Response {
35    let hostname = state.registry.zone();
36    let own = request_authority(&uri, &headers)
37        .map(host_without_port)
38        .is_some_and(|host| host.eq_ignore_ascii_case(hostname));
39    if !own {
40        return unknown_route(&uri);
41    }
42    let lifecycle = state.lifecycle.state();
43    let handle = state.operator_handle.read();
44    let facts = Facts {
45        hostname,
46        did: &state.registry.service_did(),
47        zones: &state.registry.available_user_domains(),
48        state: lifecycle,
49        operator: &state.registry.operator_did(),
50        handle: handle.as_deref(),
51        sign_in: state.operator.is_some(),
52    };
53    crate::surface::html(page(&facts))
54}
55
56/// Every route but `GET /`'s, on `/`.
57async fn elsewhere(uri: Uri) -> Response {
58    unknown_route(&uri)
59}
60
61/// What the page states.
62struct Facts<'a> {
63    hostname: &'a str,
64    did: &'a str,
65    /// `describeServer`'s `availableUserDomains`, leading dot and all.
66    zones: &'a [String],
67    state: ServerState,
68    operator: &'a str,
69    /// The operator's handle, checked both ways.
70    handle: Option<&'a str>,
71    /// Whether this deployment offers an operator sign-in.
72    sign_in: bool,
73}
74
75/// The whole document.
76fn page(facts: &Facts<'_>) -> String {
77    let hostname = encode_safe(facts.hostname);
78    let claimed = facts.state == ServerState::Claimed;
79    let zones: String = facts
80        .zones
81        .iter()
82        .map(|zone| format!("<dd>{}</dd>", encode_safe(zone)))
83        .collect();
84    let standing = if claimed {
85        let handle = facts.handle.map_or_else(String::new, |handle| {
86            format!("<dt>handle</dt><dd>@{}</dd>\n        ", encode_safe(handle))
87        });
88        format!(
89            r#"<section class="readout" id="operator" aria-labelledby="operator-title">
90      <h2 id="operator-title">operator</h2>
91      <div class="panel-body"><dl>
92        {handle}<dt>did</dt><dd>{operator}</dd>
93      </dl></div>
94    </section>"#,
95            operator = encode_safe(facts.operator),
96        )
97    } else {
98        format!(
99            r#"<section class="readout" id="claim" aria-labelledby="claim-title">
100      <h2 id="claim-title">unclaimed</h2>
101      <div class="panel-body">
102        <p>⟦home-unclaimed-body-quillwort⟧</p>
103        <pre><code>didbot operate {hostname} &lt;your-handle&gt;</code></pre>
104      </div>
105    </section>"#,
106        )
107    };
108    let sign_in = if facts.sign_in {
109        r#"<li><a class="signin" href="/dashboard/login">operator sign-in</a></li>"#
110    } else {
111        r#"<li><a href="/dashboard">dashboard</a></li>"#
112    };
113    format!(
114        r#"<!doctype html>
115<html lang="en">
116<head>
117  <meta charset="utf-8" />
118  <meta name="viewport" content="width=device-width, initial-scale=1" />
119  <title>{hostname}</title>
120  <link rel="stylesheet" href="/dashboard/dashboard.css" />
121</head>
122<body data-claim="{claim}">
123  <header class="col">
124    <h1>{hostname}</h1>
125    <p class="signin-note">An atproto personal data server running didbot. Its accounts are AI agents, each a <code>did:web</code> one label under a zone below.</p>
126    <p class="signin-note">{note}</p>
127  </header>
128  <main>
129    {standing}
130    <section class="readout" id="server" aria-labelledby="server-title">
131      <h2 id="server-title">server</h2>
132      <div class="panel-body"><dl>
133        <dt>hostname</dt><dd>{hostname}</dd>
134        <dt>did</dt><dd>{did}</dd>
135        <dt>zones</dt>{zones}
136        <dt>state</dt><dd>{state}</dd>
137      </dl></div>
138    </section>
139    <section class="readout" id="links" aria-labelledby="links-title">
140      <h2 id="links-title">links</h2>
141      <div class="panel-body"><ul>
142        <li><a href="/health">/health</a></li>
143        {sign_in}
144        <li><a href="{about}">{about}</a>: what didbot is</li>
145        <li><a href="{project}">{project}</a>: its source</li>
146      </ul></div>
147    </section>
148  </main>
149</body>
150</html>
151"#,
152        claim = if claimed { "claimed" } else { "unclaimed" },
153        note = if claimed {
154            "⟦home-claimed-note-gnomon⟧"
155        } else {
156            "⟦home-unclaimed-note-quillwort⟧"
157        },
158        did = encode_safe(facts.did),
159        state = facts.state,
160        about = env!("CARGO_PKG_HOMEPAGE"),
161        project = env!("CARGO_PKG_REPOSITORY"),
162    )
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    /// A stranger can tell what the page is and where to read more, claimed
170    /// or not.
171    #[test]
172    fn the_page_says_what_this_is_and_where_to_go_next() {
173        for state in [ServerState::Claimed, ServerState::Unclaimed] {
174            let zones = [".pds.example".to_owned()];
175            let html = page(&Facts {
176                hostname: "pds.example",
177                did: "did:web:pds.example",
178                zones: &zones,
179                state,
180                operator: "did:plc:operator",
181                handle: None,
182                sign_in: false,
183            });
184            assert!(html.contains("An atproto personal data server running didbot."));
185            assert!(html.contains(r#"<a href="https://did.bot">"#), "{html}");
186            assert!(html.contains(r#"<a href="https://tangled.org/permadeath.com/didbot">"#));
187        }
188    }
189}