forked from
atscan.net/plcbundle-rs
High-performance implementation of plcbundle written in Rust
1// Root page handler
2
3use crate::constants;
4use crate::format::{format_number, format_std_duration_verbose};
5use crate::server::ServerState;
6use crate::server::utils::extract_base_url;
7use axum::{
8 extract::State,
9 http::{HeaderMap, HeaderValue, StatusCode, Uri},
10 response::IntoResponse,
11};
12
13pub async fn handle_root(
14 State(state): State<ServerState>,
15 uri: Uri,
16 headers: HeaderMap,
17) -> impl IntoResponse {
18 let index = state.manager.get_index();
19 let bundle_count = index.bundles.len();
20 let origin = state.manager.get_plc_origin();
21 let uptime = state.start_time.elapsed();
22 let mempool_stats_opt = if state.config.sync_mode {
23 state.manager.get_mempool_stats().ok()
24 } else {
25 None
26 };
27
28 let mut response = String::new();
29
30 // ASCII art banner
31 response.push('\n');
32 response.push_str(&crate::server::get_ascii_art_banner(&state.config.version));
33 response.push('\n');
34 response.push_str(&format!(" {} server\n\n", constants::BINARY_NAME));
35 response.push_str("*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*\n");
36 response.push_str("| ⚠️ Preview Version – Do Not Use In Production! |\n");
37 response.push_str("*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*\n");
38 response.push_str("| This project and plcbundle specification is currently |\n");
39 response.push_str("| unstable and under heavy development. Things can break at |\n");
40 response.push_str("| any time. Do not use this for production systems. |\n");
41 response.push_str("| Please wait for the 1.0 release. |\n");
42 response.push_str("|________________________________________________________________|\n");
43 response.push('\n');
44 response.push_str("What is PLC Bundle?\n");
45 response.push_str("━━━━━━━━━━━━━━━━━━━━\n");
46 response.push_str("plcbundle archives AT Protocol's DID PLC Directory operations into\n");
47 response.push_str("immutable, cryptographically-chained bundles of 10,000 operations.\n\n");
48 response.push_str("More info: https://tangled.org/@atscan.net/plcbundle\n\n");
49
50 if bundle_count > 0 {
51 let first_bundle = index.bundles.first().map(|b| b.bundle_number).unwrap_or(0);
52 let last_bundle = index.last_bundle;
53 let total_size: u64 = index.bundles.iter().map(|b| b.compressed_size).sum();
54 let total_uncompressed: u64 = index.bundles.iter().map(|b| b.uncompressed_size).sum();
55
56 response.push_str("Bundles\n");
57 response.push_str("━━━━━━━\n");
58 response.push_str(&format!(" Origin: {}\n", origin));
59 response.push_str(&format!(" Bundle count: {}\n", bundle_count));
60
61 if let Some(last_meta) = index.get_bundle(last_bundle) {
62 response.push_str(&format!(
63 " Last bundle: {} ({})\n",
64 last_bundle,
65 last_meta.end_time.split('T').next().unwrap_or("")
66 ));
67 }
68
69 response.push_str(&format!(
70 " Range: {} - {}\n",
71 first_bundle, last_bundle
72 ));
73 response.push_str(&format!(
74 " Total size: {:.2} MB\n",
75 total_size as f64 / (1000.0 * 1000.0)
76 ));
77 response.push_str(&format!(
78 " Uncompressed: {:.2} MB ({:.2}x)\n",
79 total_uncompressed as f64 / (1000.0 * 1000.0),
80 total_uncompressed as f64 / total_size as f64
81 ));
82
83 if let Some(first_meta) = index.get_bundle(first_bundle) {
84 response.push_str(&format!("\n Root: {}\n", first_meta.hash));
85 }
86 if let Some(last_meta) = index.get_bundle(last_bundle) {
87 response.push_str(&format!(" Head: {}\n", last_meta.hash));
88 }
89 }
90
91 if let Some(mempool_stats) = mempool_stats_opt.as_ref() {
92 response.push_str("\nMempool\n");
93 response.push_str("━━━━━━━\n");
94 response.push_str(&format!(
95 " Target bundle: {}\n",
96 mempool_stats.target_bundle
97 ));
98 response.push_str(&format!(
99 " Operations: {} / {}\n",
100 mempool_stats.count,
101 constants::BUNDLE_SIZE
102 ));
103
104 if mempool_stats.count > 0 {
105 let progress = (mempool_stats.count as f64 / constants::BUNDLE_SIZE as f64) * 100.0;
106 response.push_str(&format!(" Progress: {:.1}%\n", progress));
107
108 let bar_width = 50;
109 let filled = ((bar_width as f64)
110 * (mempool_stats.count as f64 / constants::BUNDLE_SIZE as f64))
111 as usize;
112 let bar =
113 "█".repeat(filled.min(bar_width)) + &"░".repeat(bar_width.saturating_sub(filled));
114 response.push_str(&format!(" [{}]\n", bar));
115
116 if let Some(first_time) = mempool_stats.first_time {
117 response.push_str(&format!(
118 " First op: {}\n",
119 first_time.format("%Y-%m-%d %H:%M:%S")
120 ));
121 }
122 if let Some(last_time) = mempool_stats.last_time {
123 response.push_str(&format!(
124 " Last op: {}\n",
125 last_time.format("%Y-%m-%d %H:%M:%S")
126 ));
127 }
128 } else {
129 response.push_str(" (empty)\n");
130 }
131 }
132
133 if state.config.enable_resolver {
134 response.push_str("\nDID Resolver\n");
135 response.push_str("━━━━━━━━━━━━\n");
136 response.push_str(" Status: enabled\n");
137
138 let did_stats = state.manager.get_did_index_stats();
139 if did_stats
140 .get("exists")
141 .and_then(|v| v.as_bool())
142 .unwrap_or(false)
143 {
144 let indexed_dids = did_stats
145 .get("total_dids")
146 .and_then(|v| v.as_i64())
147 .unwrap_or(0) as u64;
148 let mempool_dids = mempool_stats_opt
149 .as_ref()
150 .and_then(|s| s.did_count)
151 .unwrap_or(0) as u64;
152
153 let total_dids = indexed_dids + mempool_dids;
154 response.push_str(&format!(
155 " DIDs: {} (Bundles {} + Mempool {})\n",
156 format_number(total_dids),
157 format_number(indexed_dids),
158 format_number(mempool_dids)
159 ));
160 }
161 response.push('\n');
162 }
163
164 response.push_str("Server Stats\n");
165 response.push_str("━━━━━━━━━━━━\n");
166 response.push_str(&format!(
167 " Version: v{} (rust)\n",
168 state.config.version
169 ));
170 response.push_str(&format!(
171 " Sync mode: {}\n",
172 state.config.sync_mode
173 ));
174 response.push_str(&format!(
175 " WebSocket: {}\n",
176 state.config.enable_websocket
177 ));
178 if let Some(handle_resolver) = state.manager.get_handle_resolver_base_url() {
179 response.push_str(&format!(" Handle Resolver: {}\n", handle_resolver));
180 } else {
181 response.push_str(" Handle Resolver: (not configured)\n");
182 }
183 response.push_str(&format!(
184 " Uptime: {}\n",
185 format_std_duration_verbose(uptime)
186 ));
187
188 // Get base URL from request
189 let base_url = extract_base_url(&headers, &uri);
190 response.push_str("\n\nAPI Endpoints\n");
191 response.push_str("━━━━━━━━━━━━━\n");
192 response.push_str(" GET / This info page\n");
193 response.push_str(" GET /index.json Full bundle index\n");
194 response.push_str(" GET /bundle/:number Bundle metadata (JSON)\n");
195 response.push_str(" GET /data/:number Raw bundle (zstd compressed)\n");
196 response.push_str(" GET /jsonl/:number Decompressed JSONL stream\n");
197 response.push_str(" GET /op/:cursor Get single operation\n");
198 response.push_str(" GET /status Server status\n");
199 response.push_str(" GET /mempool Mempool operations (JSONL)\n");
200
201 if state.config.enable_websocket {
202 response.push_str("\nWebSocket Endpoints\n");
203 response.push_str("━━━━━━━━━━━━━━━━━━━━━━━━\n");
204 response.push_str(" WS /ws Live stream (new operations only)\n");
205 response.push_str(" WS /ws?cursor=0 Stream all from beginning\n");
206 response.push_str(" WS /ws?cursor=N Stream from cursor N\n\n");
207 }
208
209 if state.config.enable_resolver {
210 response.push_str("\nDID Resolution\n");
211 response.push_str("━━━━━━━━━━━━━━\n");
212 response.push_str(" GET /:did DID Document (W3C format)\n");
213 response.push_str(" GET /:did/data PLC State (raw format)\n");
214 response.push_str(" GET /:did/log/audit Operation history\n");
215 response.push_str(" GET /random Random DID sample (JSON)\n");
216 }
217
218 response.push_str("\nCursor Format\n");
219 response.push_str("━━━━━━━━━━━━━\n");
220 response.push_str(" Global record number: ((bundle - 1) × 10,000) + position\n");
221 response.push_str(" Example: global 0 = bundle 1, position 0\n");
222 response.push_str(" Default: starts from latest (skips all historical data)\n");
223 response.push_str(" Positions are 0-indexed (per bundle: 0..9,999)\n");
224 response.push_str(" Example: global 10000 = bundle 2, position 0\n");
225
226 let bundled_ops = crate::constants::total_operations_from_bundles(index.last_bundle);
227 let mempool_ops = mempool_stats_opt
228 .as_ref()
229 .map(|s| s.count as u64)
230 .unwrap_or(0);
231 let current_latest = bundled_ops + mempool_ops;
232
233 if mempool_ops > 0 {
234 response.push_str(&format!(
235 " Current latest: {} ({} bundled + {} mempool)\n\n",
236 format_number(current_latest),
237 format_number(bundled_ops),
238 format_number(mempool_ops)
239 ));
240 } else {
241 response.push_str(&format!(
242 " Current latest: {} ({} bundled)\n\n",
243 format_number(current_latest),
244 format_number(bundled_ops)
245 ));
246 }
247
248 response.push_str("\nExamples\n");
249 response.push_str("━━━━━━━━\n");
250 response.push_str(&format!(" curl {}/bundle/1\n", base_url));
251 response.push_str(&format!(
252 " curl {}/data/42 -o 000042.jsonl.zst\n",
253 base_url
254 ));
255 response.push_str(&format!(" curl {}/jsonl/1\n", base_url));
256 response.push_str(&format!(" curl {}/op/0\n", base_url));
257 response.push_str(&format!(" curl {}/random?count=10&seed=12345\n", base_url));
258
259 if state.config.sync_mode {
260 response.push_str(&format!(" curl {}/status\n", base_url));
261 response.push_str(&format!(" curl {}/mempool\n", base_url));
262 }
263
264 if state.config.enable_websocket {
265 let ws_url = if base_url.starts_with("http://") {
266 base_url.replace("http://", "ws://")
267 } else if base_url.starts_with("https://") {
268 base_url.replace("https://", "wss://")
269 } else {
270 format!("ws://{}", base_url)
271 };
272 response.push_str(&format!(" websocat {}/ws\n", ws_url));
273 response.push_str(&format!(" websocat '{}/ws?cursor=0'\n", ws_url));
274 }
275
276 response.push_str("\n────────────────────────────────────────────────────────────────\n");
277 response.push_str("https://tangled.org/@atscan.net/plcbundle\n");
278
279 let mut headers = HeaderMap::new();
280 headers.insert(
281 "Content-Type",
282 HeaderValue::from_static("text/plain; charset=utf-8"),
283 );
284 (StatusCode::OK, headers, response).into_response()
285}