1use 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
25pub fn routes() -> Router<AppState> {
30 Router::new().route("/", get(home).fallback(elsewhere))
31}
32
33async 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
56async fn elsewhere(uri: Uri) -> Response {
58 unknown_route(&uri)
59}
60
61struct Facts<'a> {
63 hostname: &'a str,
64 did: &'a str,
65 zones: &'a [String],
67 state: ServerState,
68 operator: &'a str,
69 handle: Option<&'a str>,
71 sign_in: bool,
73}
74
75fn 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} <your-handle></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 #[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}