Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_steel/src/srv/console/mod.rs

89.3 KiB, 221 runs

created by r1870400018:14620, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! The site console: a site administered from within itself.
2//!
3//! # Two kinds of administration, told apart
4//!
5//! Steel already has an operator dashboard at `/admin`. That is the *server's* -- the wallet, the
6//! certificates, the traffic, the seal: the fuse box for the whole host, shared by every site on it.
7//! Signing into it means proving the wallet passphrase, which is also what unseals the databases.
8//!
9//! This is a different thing. A *site's* administration -- writing its posts, and in time its
10//! settings -- is the site's own concern, not the host's. The person who runs Elearnity should reach
11//! it from Elearnity, in Elearnity's own look, without being sent to a panel that also runs every
12//! other site and holds the keys to the machine.
13//!
14//! The two were conflated because the fast path put content behind the operator session: it already
15//! existed and already held the master key the database needed. But by the time a request to write a
16//! post arrives, the database is long unsealed -- that happened once, at boot. A site admin never
17//! needs the wallet. So the tiers separate cleanly: the operator holds the host, a site admin holds a
18//! site, and the only thing they share is that neither can work until the operator has unsealed at
19//! boot.
20//!
21//! # Who a site admin is
22//!
23//! An ordinary member of the site whose username the operator has listed in the vhost's
24//! [`site_admins`](crate::srv::cfg::VhostConfig::site_admins). There is no separate admin account, no
25//! separate password, and no separate login: the site's own member login is the admin login, and the
26//! authority is nothing but being on the list. A member signs in as they always do; if they are on
27//! the list, the console opens.
28//!
29//! The list lives in config, not in the site's database, on purpose. The operator owns the host and
30//! says who runs each site; a content bug -- the kind this codebase has found more than one of --
31//! must not be able to mint an administrator. Authority is the operator's grant, held where the
32//! database cannot reach it.
33//!
34//! # Why the member cookie reaches here and the operator cookie would not
35//!
36//! The operator's session cookie is `Path=/admin`, so a browser sends it to `/admin` and nowhere
37//! else -- which is why the first cut of the composer was trapped under `/admin`. The member's
38//! session cookie is `Path=/`, so it is already sent to `/manage` and every other site path. The
39//! console lives where the credential that opens it is actually presented.
40//!
41//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
42//! Anthropic Claude
43
44pub mod publish;
45pub mod session;
46
47use crate::srv::{
48 admin::{
49 assets::html_escape,
50 auth::{
51 self,
52 LoginOutcome,
53 },
54 state::AdminState,
55 },
56 cache,
57 publish::{
58 PublishConfig,
59 send::MailSender,
60 store,
61 },
62};
63
64use oxedyne_fe2o3_core::prelude::*;
65use oxedyne_fe2o3_hash::hash::HashScheme;
66use oxedyne_fe2o3_iop_crypto::enc::Encrypter;
67use oxedyne_fe2o3_iop_db::api::Database;
68use oxedyne_fe2o3_iop_hash::api::Hasher;
69use oxedyne_fe2o3_jdat::{
70 prelude::*,
71 id::NumIdDat,
72};
73use oxedyne_fe2o3_net::http::{
74 fields::{
75 Cookie,
76 HeaderFieldValue,
77 HeaderFields,
78 HeaderName,
79 SameSite,
80 SetCookieAttributes,
81 },
82 msg::HttpMessage,
83 status::HttpStatus,
84};
85
86use tokio_rustls::rustls::ClientConfig;
87
88use std::{
89 collections::BTreeSet,
90 net::SocketAddr,
91 sync::{
92 Arc,
93 RwLock,
94 },
95};
96
97
98pub const PATH_ROOT: &str = "/manage";
99
100// Whether the signed-in member may reach the console, as JSON, for the site's own chrome to ask
101// before it offers a way in. A read, so it is a GET, and it answers for anyone -- signed in or
102// not, admin or not -- rather than turning a non-admin away, because a page asking "should I show
103// the door" is not itself the door.
104pub const PATH_STATUS: &str = "/manage/status";
105
106// Where a signed-in member posts to become the site's first admin. The self-bootstrap: open only
107// while the site has no admins at all, so a member can make themselves the first one without a
108// config edit and a restart, and closed the moment there is one, so it cannot take a site that is
109// already owned.
110pub const PATH_CLAIM: &str = "/manage/claim";
111
112// The admin-management page, and where its add and remove forms post. A GET lists the site's
113// admins; a POST adds one by id-hash or removes a database-granted one. Both are gated on an
114// existing admin, so this is how a second admin is granted once the first has claimed.
115pub const PATH_ADMINS: &str = "/manage/admins";
116
117// Where a passphrase sign-in posts, and where a GET renders the themed login form. The generic
118// way in: a site owner types the operator's wallet passphrase -- the same one the `/admin`
119// dashboard verifies -- and is given a site-admin session that opens this console and nothing
120// else. No member account, no separate password, no redirect to `/admin`.
121pub const PATH_LOGIN: &str = "/manage/login";
122
123pub const PATH_LOGOUT: &str = "/manage/logout";
124
125
126/// A member the operator has entrusted with a site.
127///
128/// Nothing but a name, because that is all authority here is: the session proved who they are, and
129/// the list said they may.
130#[derive(Clone, Debug, Eq, PartialEq)]
131pub struct SiteAdmin {
132 // The site login's own identifier, which is the SHA-256 of their passphrase.
133 pub username: String,
134}
135
136
137pub fn owns(path: &str) -> bool {
138 path == PATH_ROOT
139 || (path.starts_with(PATH_ROOT)
140 && path.as_bytes().get(PATH_ROOT.len()) == Some(&b'/'))
141}
142
143
144// ┌───────────────────────────────────────────────────────────────────────────┐
145// │ THE GATE │
146// └───────────────────────────────────────────────────────────────────────────┘
147
148/// The member a request's session names, if it names one.
149///
150/// The authentication half, on its own, so the console can tell a signed-in member who is not an
151/// admin from a visitor who is not signed in at all -- the first wants their id and a way to ask for
152/// access, the second wants sending home. `Ok(None)` covers every anonymous case alike: no cookie, an
153/// expired session, a session bound to nobody.
154///
155/// `Err` is kept apart from `None`: a poisoned lock or an unreadable database is a fault to log and
156/// deny on, not a member quietly failing to be signed in.
157pub fn member_username<
158 const UIDL: usize,
159 UID: NumIdDat<UIDL>,
160 ENC: Encrypter,
161 KH: Hasher,
162 DB: Database<UIDL, UID, ENC, KH>,
163>(
164 db: Option<&(Arc<RwLock<DB>>, UID)>,
165 headers: &Arc<HeaderFields>,
166)
167 -> Outcome<Option<String>>
168{
169 let sid = match headers.get_session_id() {
170 Some(s) => s,
171 None => return Ok(None),
172 };
173 let db = match db {
174 Some(db) => db,
175 None => return Ok(None),
176 };
177 let (db_arc, _) = db;
178
179 // The session record the member's login wrote: `sess_meta:<sid>` -> `{ user }`. The same record
180 // the WebSocket handler reads to answer `whoami`, read here over HTTP because the console is
181 // pages, not sockets.
182 let meta_key = Dat::Str(fmt!("sess_meta:{}", sid));
183 let guard = lock_read!(db_arc);
184 match res!(guard.get(&meta_key, None)) {
185 Some((Dat::Map(m), _)) => match m.get(&dat!("user")) {
186 Some(Dat::Str(u)) if !u.is_empty() => Ok(Some(u.clone())),
187 // A session with no user is an anonymous one -- issued to everybody, authenticated to
188 // nobody.
189 _ => Ok(None),
190 },
191 _ => Ok(None),
192 }
193}
194
195/// The admin the request belongs to, by any of the ways one is proven.
196///
197/// Three paths, any one of which suffices, tried cheapest first:
198///
199/// 1. A **passphrase session**: a valid `manage_session` cookie ([`session`]),
200/// minted when a site owner signed in at [`PATH_LOGIN`] with the operator's
201/// wallet passphrase. The generic way in, needing no member account.
202/// 2. A **listed member**: a signed-in member whose username the operator pinned
203/// in the vhost's [`site_admins`](crate::srv::cfg::VhostConfig::site_admins).
204/// 3. A **granted member**: a signed-in member the site's own database names,
205/// the union computed in [`effective_admins`].
206///
207/// The passphrase path is checked first because it consults no database. The
208/// member paths are unchanged, so a site that signs its admins in the way it
209/// always did keeps working exactly as before.
210pub fn site_admin<
211 const UIDL: usize,
212 UID: NumIdDat<UIDL>,
213 ENC: Encrypter,
214 KH: Hasher,
215 DB: Database<UIDL, UID, ENC, KH>,
216>(
217 site_admins: &[String],
218 admin_state: Option<&AdminState>,
219 db: Option<&(Arc<RwLock<DB>>, UID)>,
220 headers: &Arc<HeaderFields>,
221)
222 -> Outcome<Option<SiteAdmin>>
223{
224 // The passphrase session: proven by the wallet passphrase at login, carried
225 // in the manage cookie, and granting this console alone.
226 if let Some(state) = admin_state {
227 if let Some(name) = session::authenticate(state, headers) {
228 return Ok(Some(SiteAdmin { username: name }));
229 }
230 }
231
232 let username = match res!(member_username(db, headers)) {
233 Some(u) => u,
234 // Not signed in as anyone, so an admin of nothing. The database is not consulted: there is no
235 // name to look for.
236 None => return Ok(None),
237 };
238 let admins = res!(effective_admins(site_admins, db));
239 if admins.iter().any(|a| a == &username) {
240 Ok(Some(SiteAdmin { username }))
241 } else {
242 Ok(None)
243 }
244}
245
246/// The seed the console's CSRF token is derived from for this request.
247///
248/// A passphrase-authed admin holds no member session id, so the token cannot be
249/// keyed on one. It is keyed instead on their `manage_session` cookie value,
250/// which is stable for the life of the session, `HttpOnly` so no script reads
251/// it, and `SameSite=Strict` so no cross-site request carries it -- the same
252/// properties the member session id has, and the same guarantee the token
253/// needs. A member falls through to their session id, exactly as before.
254///
255/// Both the issuing side ([`PATH_STATUS`]) and the checking side (the write
256/// handlers) call this, so the seed they agree on is the same one.
257fn csrf_seed(
258 admin_state: Option<&AdminState>,
259 headers: &Arc<HeaderFields>,
260)
261 -> Option<String>
262{
263 if let Some(state) = admin_state {
264 if let Some(value) = session::cookie_value(headers) {
265 if session::decode(state, &value).is_ok() {
266 return Some(value);
267 }
268 }
269 }
270 headers.get_session_id()
271}
272
273/// The effective admin set: the config's failsafe list unioned with the database's granted one.
274///
275/// A member is an admin if either set names them, so the operator's config-pinned admins are an
276/// override the database cannot touch, and the site's own grants sit alongside them. Reads the
277/// database list where there is a database and takes it as empty where there is not.
278fn effective_admins<
279 const UIDL: usize,
280 UID: NumIdDat<UIDL>,
281 ENC: Encrypter,
282 KH: Hasher,
283 DB: Database<UIDL, UID, ENC, KH>,
284>(
285 site_admins: &[String],
286 db: Option<&(Arc<RwLock<DB>>, UID)>,
287)
288 -> Outcome<Vec<String>>
289{
290 let db_admins = match db {
291 Some(d) => res!(store::admins_get(d, "console")),
292 None => Vec::new(),
293 };
294 Ok(union_admins(site_admins, &db_admins))
295}
296
297/// The union of the config admin list and the database one, config first, deduped.
298///
299/// Pure, so the union's rule -- config-pinned admins always present, database ones added where they do
300/// not repeat one -- can be reasoned about and tested without a database.
301pub fn union_admins(config: &[String], db_admins: &[String]) -> Vec<String> {
302 let mut out: Vec<String> = config.to_vec();
303 for h in db_admins {
304 if !out.iter().any(|a| a == h) {
305 out.push(h.clone());
306 }
307 }
308 out
309}
310
311/// Whether a string is a member id-hash: 64 lowercase hexadecimal characters.
312///
313/// A username here is the SHA-256 of a passphrase, rendered lowercase hex, so a grant's word for one is
314/// held to that shape before it reaches the admin list -- a browser's field is not a reason to store a
315/// name nothing could ever match.
316fn valid_id_hash(s: &str) -> bool {
317 s.len() == 64 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
318}
319
320
321// ┌───────────────────────────────────────────────────────────────────────────┐
322// │ GET │
323// └───────────────────────────────────────────────────────────────────────────┘
324
325pub async fn handle_get<
326 const UIDL: usize,
327 UID: NumIdDat<UIDL>,
328 ENC: Encrypter,
329 KH: Hasher,
330 DB: Database<UIDL, UID, ENC, KH>,
331>(
332 site_admins: &[String],
333 admin_state: Option<&AdminState>,
334 publish: Option<&PublishConfig>,
335 db: Option<&(Arc<RwLock<DB>>, UID)>,
336 request_path: &str,
337 query: &str,
338 headers: &Arc<HeaderFields>,
339 id: &str,
340)
341 -> Outcome<HttpMessage>
342{
343 // The login form, themed by the site's own look. Served to anyone: it is the
344 // way in, not a thing behind the way in. An already-signed-in admin who lands
345 // here is shown it too, harmlessly -- posting it merely refreshes their session.
346 if request_path == PATH_LOGIN {
347 return Ok(login_page(&Theme::of(publish), None));
348 }
349
350 let admin = res!(site_admin(site_admins, admin_state, db, headers));
351
352 // The status probe answers everyone, so the site's chrome can ask whether to show the way in
353 // without being redirected. It is the one console path a non-admin may read. An admin also gets
354 // the CSRF token here, since the app that draws its own management surface needs it to write and
355 // cannot read the session cookie to derive it.
356 if request_path == PATH_STATUS {
357 // Claimable: a signed-in member who is not yet an admin, on a site that has none at all. The
358 // site's own chrome reads this to offer a "Become admin" button, so the first admin bootstraps
359 // without ever seeing a config file.
360 let claimable = if admin.is_some() {
361 false
362 } else {
363 match res!(member_username(db, headers)) {
364 Some(_) => res!(effective_admins(site_admins, db)).is_empty(),
365 None => false,
366 }
367 };
368 // An admin needs the CSRF token to write; a claimable member needs it to post the claim. Both
369 // hold the seed it is derived from -- a passphrase admin's manage cookie, or a member's session
370 // id -- so both are given it here, and nobody else is.
371 let csrf = if admin.is_some() || claimable {
372 csrf_seed(admin_state, headers).map(|seed| csrf_token(&seed))
373 } else {
374 None
375 };
376 // An admin is told which remotes the site can post to, so the composer draws a picker for those
377 // and no others. A non-admin is told nothing of them. The set is the effective one -- what the
378 // console has set laid over the config -- so a remote configured from the settings form appears
379 // here at once.
380 let offered = match (&admin, publish, db) {
381 (Some(_), Some(p), Some(d)) =>
382 res!(crate::srv::publish::send::effective_creds(d, p)).offered(),
383 (Some(_), Some(p), None) => p.creds.offered(),
384 _ => Vec::new(),
385 };
386 let dests: Vec<&str> = offered.iter().map(|d| d.as_str()).collect();
387 // The site's category taxonomy, so the composer draws a checkbox per category. Only an admin
388 // composes, and only where the vhost publishes at all; a non-admin, or a site with no publish
389 // block, is given the empty set.
390 let cats: &[String] = match (&admin, publish) {
391 (Some(_), Some(p)) => &p.categories,
392 _ => &[],
393 };
394 return Ok(status_json(admin.is_some(), claimable, csrf.as_deref(), &dests, cats));
395 }
396
397 let admin = match admin {
398 Some(a) => a,
399 None => {
400 // Not an admin. A signed-in member is one no set has yet named. If the site has no admins
401 // at all, this is the bootstrap: they may claim it, and are shown the button that does.
402 // Otherwise they are shown their id, to hand to an existing admin. A visitor who is not
403 // signed in is shown the passphrase login: the generic way in, so any site with a console
404 // offers a themed sign-in without a line of its own code.
405 return match res!(member_username(db, headers)) {
406 Some(username) => {
407 let claimable = res!(effective_admins(site_admins, db)).is_empty();
408 let csrf = match headers.get_session_id() {
409 Some(s) => csrf_token(&s),
410 None => String::new(),
411 };
412 Ok(not_yet_admin(&Theme::of(publish), &username, claimable, &csrf))
413 }
414 None => Ok(login_page(&Theme::of(publish), None)),
415 };
416 }
417 };
418
419 // The token every form on the pages below carries, so the write it makes proves it came from a
420 // page the session rendered. Derived from the session's seed -- a passphrase admin's manage cookie
421 // or a member's session id -- and the same for every form in a session.
422 let csrf = match csrf_seed(admin_state, headers) {
423 Some(s) => csrf_token(&s),
424 None => return Ok(redirect(&home_of(publish))),
425 };
426
427 let theme = Theme::of(publish);
428
429 // The admin-management page needs the config admin list to tell a pinned admin from a granted one,
430 // which the post console does not carry, so it is answered here rather than passed down.
431 if request_path == PATH_ADMINS {
432 return admins_page(&theme, &admin, &csrf, site_admins, db, query, id);
433 }
434
435 publish::handle_get(publish, &theme, &admin, &csrf, db, request_path, query, id)
436}
437
438/// The admin-management page: who administers the site, and the forms to grant and revoke.
439///
440/// Config-pinned admins are shown read-only -- the operator holds them, and the database cannot remove
441/// what config asserts. Database-granted admins each carry a Remove button, and one form adds a new
442/// admin by id-hash. Dressed in the site's own chrome, like every console page.
443fn admins_page<
444 const UIDL: usize,
445 UID: NumIdDat<UIDL>,
446 ENC: Encrypter,
447 KH: Hasher,
448 DB: Database<UIDL, UID, ENC, KH>,
449>(
450 theme: &Theme,
451 admin: &SiteAdmin,
452 csrf: &str,
453 site_admins: &[String],
454 db: Option<&(Arc<RwLock<DB>>, UID)>,
455 query: &str,
456 id: &str,
457)
458 -> Outcome<HttpMessage>
459{
460 let db_admins = match db {
461 Some(d) => res!(store::admins_get(d, id)),
462 None => Vec::new(),
463 };
464
465 let mut body = String::new();
466 body.push_str("<h1>Administrators</h1>\n");
467 body.push_str(
468 "<p class=\"mc-muted\">Who may manage this site. An administrator is a member, named by the \
469 id of their account. Add one by pasting their id below; they will find it on this site's manage \
470 page when they are signed in but not yet an administrator.</p>\n");
471
472 // A grant or a refusal that redirected here said why in the query it landed with. Shown, rather
473 // than swallowed, exactly as the posts list shows its own.
474 if let Some(said) = said_field(query) {
475 body.push_str(&fmt!("<p class=\"mc-notice\">{}</p>\n", html_escape(&said)));
476 }
477
478 body.push_str("<table class=\"mc-table\">\n<thead><tr>\
479 <th>Administrator</th><th>Source</th><th></th>\
480 </tr></thead>\n<tbody>\n");
481
482 // The config-pinned admins first, read-only: the operator's grant, which the database cannot lift.
483 for h in site_admins {
484 body.push_str(&fmt!(
485 "<tr><td><span class=\"mc-slug\">{id}</span></td>\
486 <td><span class=\"mc-tag\">config</span></td>\
487 <td></td></tr>\n",
488 id = html_escape(h),
489 ));
490 }
491
492 // The database-granted admins, each with a Remove button. One that is also config-pinned is already
493 // shown above and not repeated: it cannot be removed here, so a Remove button on it would lie.
494 for h in &db_admins {
495 if site_admins.iter().any(|a| a == h) {
496 continue;
497 }
498 body.push_str(&fmt!(
499 "<tr><td><span class=\"mc-slug\">{id}</span></td>\
500 <td><span class=\"mc-tag mc-tag-live\">granted</span></td>\
501 <td>\
502 <form class=\"mc-admin-remove\" method=\"POST\" action=\"{admins}\" \
503 onsubmit=\"return confirm('Remove this administrator?')\">\
504 <input type=\"hidden\" name=\"csrf\" value=\"{csrf}\">\
505 <input type=\"hidden\" name=\"action\" value=\"remove\">\
506 <input type=\"hidden\" name=\"id\" value=\"{id}\">\
507 <button type=\"submit\" class=\"mc-btn mc-btn-danger\">Remove</button>\
508 </form>\
509 </td></tr>\n",
510 admins = PATH_ADMINS,
511 csrf = html_escape(csrf),
512 id = html_escape(h),
513 ));
514 }
515 body.push_str("</tbody>\n</table>\n");
516
517 // The add form: a member id, and the grant.
518 body.push_str(&fmt!(
519 "<form class=\"mc-form\" id=\"mc-admin-add\" method=\"POST\" action=\"{admins}\">\n\
520 <input type=\"hidden\" name=\"csrf\" value=\"{csrf}\">\n\
521 <input type=\"hidden\" name=\"action\" value=\"add\">\n\
522 <label for=\"mc-admin-id\">Add an administrator by id</label>\n\
523 <input type=\"text\" id=\"mc-admin-id\" name=\"id\" \
524 placeholder=\"64 hexadecimal characters\" autocomplete=\"off\" spellcheck=\"false\">\n\
525 <div class=\"mc-actions\">\n\
526 <button type=\"submit\" class=\"mc-btn\" id=\"mc-admin-add-btn\">Add administrator</button>\n\
527 </div>\n\
528 </form>\n",
529 admins = PATH_ADMINS,
530 csrf = html_escape(csrf),
531 ));
532
533 Ok(page(theme, admin, "Administrators", &body))
534}
535
536
537// ┌───────────────────────────────────────────────────────────────────────────┐
538// │ POST │
539// └───────────────────────────────────────────────────────────────────────────┘
540
541/// Serves the console's writes.
542///
543/// Returns `None` for a path the console does not write to, so the caller carries on down its own
544/// routing rather than turning every unknown POST under `/manage` into an error here.
545pub async fn handle_post<
546 const UIDL: usize,
547 UID: NumIdDat<UIDL>,
548 ENC: Encrypter,
549 KH: Hasher,
550 DB: Database<UIDL, UID, ENC, KH>,
551>(
552 site_admins: &[String],
553 admin_state: Option<&AdminState>,
554 publish: Option<&PublishConfig>,
555 db: Option<&(Arc<RwLock<DB>>, UID)>,
556 tls_client: &Option<Arc<ClientConfig>>,
557 mail: &Option<Arc<MailSender>>,
558 request_path: &str,
559 headers: &Arc<HeaderFields>,
560 body: &[u8],
561 peer: SocketAddr,
562 id: &str,
563)
564 -> Outcome<Option<HttpMessage>>
565{
566 // The passphrase sign-in and its matching sign-out. Answered first: they need
567 // no session and gate on nothing, they establish and clear the session the
568 // rest of the console gates on. The passphrase is verified against the wallet
569 // and never logged.
570 if request_path == PATH_LOGIN {
571 return Ok(Some(do_login(admin_state, publish, headers, body, peer, id)));
572 }
573 if request_path == PATH_LOGOUT {
574 return Ok(Some(do_logout(publish, headers)));
575 }
576 // The claim: a signed-in member becomes the first admin, gated not on being an admin -- there are
577 // none yet -- but on the set being empty. Answered before the admin gate below, which it could not
578 // pass and does not need to.
579 if request_path == PATH_CLAIM {
580 return Ok(Some(res!(do_claim(site_admins, db, headers, body, id))));
581 }
582 // The admin-management writes: an existing admin grants or revokes. Its own gate and CSRF check are
583 // inside, since it needs the config list the shared path below does not carry.
584 if request_path == PATH_ADMINS {
585 return Ok(Some(res!(do_admins(site_admins, admin_state, publish, db, headers, body, id))));
586 }
587
588 if !publish::posts(request_path) {
589 return Ok(None);
590 }
591
592 let admin = match res!(site_admin(site_admins, admin_state, db, headers)) {
593 Some(a) => a,
594 None => {
595 warn!("{}: console: a caller who is not a site admin tried to write", id);
596 return Ok(Some(redirect(&home_of(publish))));
597 }
598 };
599
600 // The cross-site guard. A member cookie is `SameSite=Lax` and a manage cookie `SameSite=Strict`,
601 // so a cross-site POST carries neither and never reaches an authenticated state at all -- this is
602 // the belt to that braces, and the thing that still holds if a cookie's policy is ever loosened.
603 // The token is a value only a page that held the session could have been given, checked against
604 // the seed the session's cookie names.
605 let seed = match csrf_seed(admin_state, headers) {
606 Some(s) => s,
607 None => return Ok(Some(redirect(&home_of(publish)))),
608 };
609 // Whether the caller is the site's own front-end asking over fetch, which wants a plain JSON
610 // answer, or a browser posting a form, which wants a redirect. The app says so with its Accept.
611 let json = wants_json(headers);
612
613 let sent = form_field(body, "csrf").unwrap_or_default();
614 if !csrf_ok(&seed, &sent) {
615 warn!("{}: console: a write arrived without a good csrf token", id);
616 return Ok(Some(if json {
617 cache::generated(HttpMessage::new_response(HttpStatus::Forbidden)
618 .with_field(
619 HeaderName::ContentType,
620 HeaderFieldValue::Generic("application/json".to_string()),
621 )
622 .with_body(fmt!("{{\"error\":\"stale session; reload\"}}").into_bytes()))
623 } else {
624 redirect(PATH_ROOT)
625 }));
626 }
627
628 let resp = res!(publish::handle_post(
629 publish, &admin, db, tls_client, mail, request_path, body, json, id,
630 ).await);
631 Ok(Some(resp))
632}
633
634/// Whether the caller wants JSON rather than a page -- the site's own front-end, over fetch, asking
635/// with `Accept: application/json`. A browser form carries no such Accept and gets a redirect.
636fn wants_json(headers: &Arc<HeaderFields>) -> bool {
637 match headers.get_one(&HeaderName::Accept) {
638 Some(HeaderFieldValue::Generic(v)) => v.contains("application/json"),
639 _ => false,
640 }
641}
642
643/// The self-bootstrap: a signed-in member becomes the site's first admin.
644///
645/// It goes through only where every guard holds: the caller is a signed-in member, the form carries a
646/// good CSRF token, and the effective admin set is empty. That last is the whole safety of it -- the
647/// claim is open while the site is unowned and shut the instant it is owned, so it can make a first
648/// admin but never displace one. On success the caller is now an admin and is sent to the console.
649fn do_claim<
650 const UIDL: usize,
651 UID: NumIdDat<UIDL>,
652 ENC: Encrypter,
653 KH: Hasher,
654 DB: Database<UIDL, UID, ENC, KH>,
655>(
656 site_admins: &[String],
657 db: Option<&(Arc<RwLock<DB>>, UID)>,
658 headers: &Arc<HeaderFields>,
659 body: &[u8],
660 id: &str,
661)
662 -> Outcome<HttpMessage>
663{
664 let json = wants_json(headers);
665
666 // A claim is a member's, so an anonymous caller has nothing to claim with.
667 let username = match res!(member_username(db, headers)) {
668 Some(u) => u,
669 None => {
670 warn!("{}: console: a claim arrived from nobody signed in", id);
671 return Ok(claim_deny(json, "sign in first"));
672 }
673 };
674
675 // The cross-site guard, as every console write has: the token proves the post came from a page that
676 // held the session.
677 let sid = match headers.get_session_id() {
678 Some(s) => s,
679 None => return Ok(claim_deny(json, "sign in first")),
680 };
681 let sent = form_field(body, "csrf").unwrap_or_default();
682 if !csrf_ok(&sid, &sent) {
683 warn!("{}: console: a claim arrived without a good csrf token", id);
684 return Ok(csrf_deny(json));
685 }
686
687 // The gate the whole thing turns on: a claim is refused the moment the site has an admin, so it can
688 // only ever mint the first.
689 let admins = res!(effective_admins(site_admins, db));
690 if !admins.is_empty() {
691 warn!("{}: console: '{}' tried to claim a site that already has admins", id, username);
692 return Ok(claim_deny(json, "this site already has an administrator"));
693 }
694
695 let d = match db {
696 Some(d) => d,
697 None => return Ok(claim_deny(json, "this site has no database configured")),
698 };
699 res!(store::admins_add(d, id, &username));
700 info!("{}: console: '{}' claimed the site as its first administrator", id, username);
701
702 Ok(if json {
703 json_ok()
704 } else {
705 redirect(PATH_ROOT)
706 })
707}
708
709/// The admin-management writes: an existing admin grants a new admin or revokes a granted one.
710///
711/// Gated on an existing admin and CSRF-checked, both here since it needs the config list to keep a
712/// pinned admin from being revoked -- removing one from the database would leave it effective anyway,
713/// so the refusal is the honest answer rather than a silent no-op. A grant validates the id-hash's
714/// shape before it reaches the list; a revoke touches the database list alone.
715fn do_admins<
716 const UIDL: usize,
717 UID: NumIdDat<UIDL>,
718 ENC: Encrypter,
719 KH: Hasher,
720 DB: Database<UIDL, UID, ENC, KH>,
721>(
722 site_admins: &[String],
723 admin_state: Option<&AdminState>,
724 publish: Option<&PublishConfig>,
725 db: Option<&(Arc<RwLock<DB>>, UID)>,
726 headers: &Arc<HeaderFields>,
727 body: &[u8],
728 id: &str,
729)
730 -> Outcome<HttpMessage>
731{
732 let json = wants_json(headers);
733
734 let admin = match res!(site_admin(site_admins, admin_state, db, headers)) {
735 Some(a) => a,
736 None => {
737 warn!("{}: console: a non-admin tried to manage the admin list", id);
738 return Ok(redirect(&home_of(publish)));
739 }
740 };
741
742 let seed = match csrf_seed(admin_state, headers) {
743 Some(s) => s,
744 None => return Ok(redirect(&home_of(publish))),
745 };
746 let sent = form_field(body, "csrf").unwrap_or_default();
747 if !csrf_ok(&seed, &sent) {
748 warn!("{}: console: an admin-list write arrived without a good csrf token", id);
749 return Ok(csrf_deny(json));
750 }
751
752 let d = match db {
753 Some(d) => d,
754 None => return Ok(admins_deny(json, "this site has no database configured")),
755 };
756
757 let action = form_field(body, "action").unwrap_or_default();
758 let hash = form_field(body, "id").unwrap_or_default().trim().to_string();
759
760 match action.as_str() {
761 "add" => {
762 if !valid_id_hash(&hash) {
763 return Ok(admins_deny(
764 json, "an administrator's id is 64 lowercase hexadecimal characters"));
765 }
766 res!(store::admins_add(d, id, &hash));
767 info!("{}: console: '{}' granted admin to '{}'", id, admin.username, hash);
768 }
769 "remove" => {
770 // A config-pinned admin is the operator's, and stays effective whatever the database says;
771 // removing it here would be a lie the union unpicks, so it is refused outright.
772 if site_admins.iter().any(|a| a == &hash) {
773 return Ok(admins_deny(
774 json, "that administrator is pinned in the site's configuration and cannot be \
775 removed here"));
776 }
777 res!(store::admins_remove(d, id, &hash));
778 info!("{}: console: '{}' revoked admin from '{}'", id, admin.username, hash);
779 }
780 other => return Ok(admins_deny(json, &fmt!("'{}' is not an action here", other))),
781 }
782
783 Ok(if json {
784 json_ok()
785 } else {
786 redirect(PATH_ADMINS)
787 })
788}
789
790
791// ┌───────────────────────────────────────────────────────────────────────────┐
792// │ PASSPHRASE SIGN-IN │
793// └───────────────────────────────────────────────────────────────────────────┘
794
795/// The passphrase sign-in: verify the operator's wallet passphrase and, on
796/// success, issue a site-admin session for this console.
797///
798/// The passphrase is checked with the same [`auth::verify_passphrase`] the
799/// `/admin` dashboard uses, so there is one credential and one check, not two
800/// that could drift. It is never logged. Success answers a fetch caller with
801/// `{"ok":true}` and a browser form with a 303 to the console, both carrying the
802/// `Set-Cookie`; failure answers `{"ok":false,"error":...}` or re-renders the
803/// themed form. The session it mints opens this console alone -- it is not, and
804/// cannot become, an `/admin` operator session.
805fn do_login(
806 admin_state: Option<&AdminState>,
807 publish: Option<&PublishConfig>,
808 headers: &Arc<HeaderFields>,
809 body: &[u8],
810 peer: SocketAddr,
811 id: &str,
812)
813 -> HttpMessage
814{
815 let theme = Theme::of(publish);
816 let json = wants_json(headers);
817
818 // A site whose vhost has no admin state configured cannot verify a wallet
819 // passphrase, so it has no passphrase sign-in to offer.
820 let state = match admin_state {
821 Some(s) => s,
822 None => {
823 warn!("{}: console: a passphrase login arrived where no admin state is configured", id);
824 return login_deny(&theme, json, "sign-in is not available on this site");
825 }
826 };
827
828 let passphrase = match form_field(body, "passphrase") {
829 Some(p) => p,
830 None => return login_deny(&theme, json, "a passphrase is required"),
831 };
832
833 // The passphrase is proven against the wallet here. Whatever the outcome, it
834 // is never written to a log line.
835 let outcome = match auth::verify_passphrase(state, passphrase.as_bytes(), peer) {
836 Ok(o) => o,
837 Err(e) => {
838 error!(e, "{}: console: structural error verifying a manage passphrase", id);
839 return login_deny(&theme, json, "an internal error prevented sign-in");
840 }
841 };
842
843 match outcome {
844 LoginOutcome::Ok(principal) => {
845 let value = match session::encode(state, &principal.name) {
846 Ok(v) => v,
847 Err(e) => {
848 error!(e, "{}: console: could not encode a manage session", id);
849 return login_deny(&theme, json, "sign-in succeeded but the session could not be issued");
850 }
851 };
852 info!("{}: console: '{}' signed in to manage via the wallet passphrase", id, principal.name);
853 let cookie = build_manage_cookie(value, false);
854 if json {
855 json_ok().set_cookie(cookie)
856 } else {
857 redirect(PATH_ROOT).set_cookie(cookie)
858 }
859 }
860 LoginOutcome::BadCredentials => {
861 warn!("{}: console: a passphrase login failed on credentials from {}", id, peer.ip());
862 login_deny(&theme, json, "the passphrase was not accepted")
863 }
864 LoginOutcome::NoDashboardScope { name } => {
865 // The passphrase unwrapped the wallet but the admin holds no dashboard
866 // scope. The console mirrors the dashboard's own rule: an admin gated
867 // out of the dashboard is gated out of the console.
868 warn!("{}: console: '{}' authenticated but holds no dashboard scope", id, name);
869 login_deny(&theme, json, "this account is not authorised to manage sites")
870 }
871 }
872}
873
874/// The sign-out: clear the site-admin session and send the caller on.
875///
876/// Stateless, like the operator logout: the session lives only in the cookie, so
877/// evicting the cookie is the whole of it. A fetch caller gets `{"ok":true}`, a
878/// browser a redirect to the site home.
879fn do_logout(
880 publish: Option<&PublishConfig>,
881 headers: &Arc<HeaderFields>,
882)
883 -> HttpMessage
884{
885 let json = wants_json(headers);
886 let cookie = build_manage_cookie(String::new(), true);
887 if json {
888 json_ok().set_cookie(cookie)
889 } else {
890 redirect(&home_of(publish)).set_cookie(cookie)
891 }
892}
893
894/// A sign-in that did not go through: the reason for a fetch caller as
895/// `{"ok":false,"error":...}`, or the themed form again for a browser.
896///
897/// The reasons are deliberately plain and do not distinguish a wrong passphrase
898/// from an unauthorised admin -- the same discretion the dashboard login keeps,
899/// so the response never says whether a given passphrase was close.
900fn login_deny(theme: &Theme, json: bool, why: &str) -> HttpMessage {
901 if json {
902 cache::generated(HttpMessage::new_response(HttpStatus::OK)
903 .with_field(
904 HeaderName::ContentType,
905 HeaderFieldValue::Generic("application/json".to_string()),
906 )
907 .with_body(fmt!("{{\"ok\":false,\"error\":\"{}\"}}", json_escape(why)).into_bytes()))
908 } else {
909 login_page(theme, Some(why))
910 }
911}
912
913/// Build the `Set-Cookie` value for the site-admin session under
914/// [`session::MANAGE_COOKIE_NAME`].
915///
916/// `Path=/` so the browser sends it to `/manage` -- unlike the operator cookie's
917/// `Path=/admin`, which never reaches here. `HttpOnly` so no script reads it,
918/// `Secure` so it rides only TLS, and `SameSite=Strict` so no cross-site request
919/// carries it. `clear` produces the `Max-Age=0` eviction the sign-out uses.
920fn build_manage_cookie(value: String, clear: bool) -> Cookie {
921 let mut attrs: BTreeSet<SetCookieAttributes> = BTreeSet::new();
922 attrs.insert(SetCookieAttributes::Path("/".to_string()));
923 attrs.insert(SetCookieAttributes::HttpOnly);
924 attrs.insert(SetCookieAttributes::Secure);
925 attrs.insert(SetCookieAttributes::SameSite(SameSite::Strict));
926 if clear {
927 attrs.insert(SetCookieAttributes::MaxAge(0));
928 }
929 Cookie {
930 key: session::MANAGE_COOKIE_NAME.to_string(),
931 val: value,
932 attrs: Some(attrs),
933 }
934}
935
936/// The themed passphrase login page: one password field, posting to
937/// [`PATH_LOGIN`], dressed in the site's own look.
938///
939/// Rendered for any visitor who reaches the console without an admin session, so
940/// every site with a console has a sign-in for free, in its own skin, with no
941/// app code. A bespoke front-end may replace it with a popup over the same
942/// endpoint, but it is never required. No CSRF token: there is no session yet to
943/// protect, and the credential is the operator's own passphrase.
944fn login_page(theme: &Theme, error: Option<&str>) -> HttpMessage {
945 let notice = match error {
946 Some(msg) => fmt!("<p class=\"mc-notice mc-notice-err\">{}</p>\n", html_escape(msg)),
947 None => String::new(),
948 };
949 let body = fmt!(
950 "<h1>Manage this site</h1>\n\
951 <p class=\"mc-muted\">Sign in with the site's management passphrase to write its posts and \
952 settings.</p>\n\
953 {notice}\
954 <form class=\"mc-form\" id=\"mc-login\" method=\"POST\" action=\"{login}\">\n\
955 <label for=\"mc-passphrase\">Passphrase</label>\n\
956 <input type=\"password\" id=\"mc-passphrase\" name=\"passphrase\" autocomplete=\"current-password\" \
957 autofocus required>\n\
958 <div class=\"mc-actions\">\n\
959 <button type=\"submit\" class=\"mc-btn\" id=\"mc-login-btn\">Sign in</button>\n\
960 </div>\n\
961 </form>\n",
962 notice = notice,
963 login = PATH_LOGIN,
964 );
965
966 // A bare themed page, without the admin nav: the visitor is not an admin yet,
967 // so a nav to pages they cannot open would only mislead.
968 let mut s = String::new();
969 s.push_str("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n");
970 s.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
971 s.push_str("<meta name=\"robots\" content=\"noindex\">\n<title>");
972 if !theme.site_name.is_empty() {
973 s.push_str(&html_escape(&theme.site_name));
974 s.push_str(" — ");
975 }
976 s.push_str("manage</title>\n");
977 for href in &theme.css {
978 s.push_str("<link rel=\"stylesheet\" href=\"");
979 s.push_str(&html_escape(href));
980 s.push_str("\">\n");
981 }
982 s.push_str("<style>\n");
983 s.push_str(CONSOLE_CSS);
984 s.push_str("</style>\n</head>\n<body class=\"mc-body\">\n<main class=\"mc-main\">\n");
985 s.push_str(&body);
986 s.push_str("</main>\n</body>\n</html>\n");
987
988 // Never held. Held, this page would be shown to somebody already signed in, and would
989 // carry any notice the last attempt earned back to whoever asked next.
990 cache::generated(
991 HttpMessage::new_response(HttpStatus::OK)
992 .with_field(
993 HeaderName::ContentType,
994 HeaderFieldValue::Generic("text/html; charset=utf-8".to_string()),
995 )
996 .with_body(s.into_bytes())
997 )
998}
999
1000
1001// ┌───────────────────────────────────────────────────────────────────────────┐
1002// │ CHROME │
1003// └───────────────────────────────────────────────────────────────────────────┘
1004
1005/// What a console page needs to look like the site it belongs to.
1006///
1007/// Drawn from the site's publish block, because that is where a site already says its name and names
1008/// its stylesheets, and the console wearing the same look means the operator never leaves the site to
1009/// manage it. A site with a console but no publish block gets a plain page that still works -- the
1010/// look is a courtesy, the function is not.
1011pub struct Theme {
1012 pub site_name: String, // for the tab and the header
1013 pub css: Vec<String>, // the site's own stylesheets, so the console inherits its palette
1014 pub home: String, // where "View site" goes
1015}
1016
1017impl Theme {
1018
1019 fn of(publish: Option<&PublishConfig>) -> Self {
1020 match publish {
1021 Some(p) => Self {
1022 site_name: p.site_name.clone(),
1023 css: p.css.clone(),
1024 home: if p.base_url.is_empty() { fmt!("/") } else { p.base_url.clone() },
1025 },
1026 None => Self {
1027 site_name: String::new(),
1028 css: Vec::new(),
1029 home: fmt!("/"),
1030 },
1031 }
1032 }
1033}
1034
1035fn home_of(publish: Option<&PublishConfig>) -> String {
1036 match publish {
1037 Some(p) if !p.base_url.is_empty() => p.base_url.clone(),
1038 _ => fmt!("/"),
1039 }
1040}
1041
1042/// Wraps a console body in the site's own look.
1043///
1044/// The site's stylesheets are linked for their custom properties -- colours, fonts -- and a small
1045/// sheet of the console's own, which consumes those properties where they are set and falls back
1046/// where they are not, dresses the forms and tables the site's own stylesheets never had a reason to.
1047/// So the console reads as part of the site without the site having authored a line of admin styling.
1048pub fn page(theme: &Theme, admin: &SiteAdmin, title: &str, body: &str) -> HttpMessage {
1049 let mut s = String::new();
1050 s.push_str("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n");
1051 s.push_str("<meta charset=\"utf-8\">\n");
1052 s.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
1053 s.push_str("<meta name=\"robots\" content=\"noindex\">\n");
1054
1055 s.push_str("<title>");
1056 s.push_str(&html_escape(title));
1057 if !theme.site_name.is_empty() {
1058 s.push_str(" — ");
1059 s.push_str(&html_escape(&theme.site_name));
1060 s.push_str(" manage");
1061 }
1062 s.push_str("</title>\n");
1063
1064 for href in &theme.css {
1065 s.push_str("<link rel=\"stylesheet\" href=\"");
1066 s.push_str(&html_escape(href));
1067 s.push_str("\">\n");
1068 }
1069 s.push_str("<style>\n");
1070 s.push_str(CONSOLE_CSS);
1071 s.push_str("</style>\n");
1072
1073 s.push_str("</head>\n<body class=\"mc-body\">\n");
1074
1075 // The header: whose site, that this is the management of it, and the two ways out -- back to the
1076 // site, and sign out.
1077 s.push_str("<header class=\"mc-head\">\n<div class=\"mc-head-in\">\n");
1078 s.push_str("<div class=\"mc-brand\">");
1079 if !theme.site_name.is_empty() {
1080 s.push_str(&html_escape(&theme.site_name));
1081 } else {
1082 s.push_str("Manage");
1083 }
1084 s.push_str(" <span class=\"mc-brand-sub\">manage</span></div>\n");
1085 s.push_str("<nav class=\"mc-nav\">");
1086 s.push_str(&fmt!("<a href=\"{}\">Posts</a>", PATH_ROOT));
1087 s.push_str(&fmt!("<a href=\"{}\">Subscribers</a>", publish::PATH_SUBS));
1088 s.push_str(&fmt!("<a href=\"{}\">Reports</a>", publish::PATH_REPORTS));
1089 s.push_str(&fmt!("<a href=\"{}\">Comments</a>", publish::PATH_COMMENTS));
1090 s.push_str(&fmt!("<a href=\"{}\">Destinations</a>", publish::PATH_DESTS));
1091 s.push_str(&fmt!("<a href=\"{}\">AI</a>", publish::PATH_AI));
1092 s.push_str(&fmt!("<a href=\"{}\">Declarations</a>", publish::PATH_DECLARE));
1093 s.push_str(&fmt!("<a href=\"{}\">Profile</a>", publish::PATH_PROFILE));
1094 s.push_str(&fmt!("<span class=\"mc-who\">{}…</span>", html_escape(&admin.username[..8.min(admin.username.len())])));
1095 // The way out of the console is a close, in the corner, as it is on every page within it --
1096 // rather than a link competing for attention with the pages themselves.
1097 s.push_str(&fmt!(
1098 "<a class=\"mc-close\" href=\"{home}\" title=\"Back to the site\" \
1099 aria-label=\"Back to the site\">{close}</a>",
1100 home = html_escape(&theme.home),
1101 close = publish::icon_close(),
1102 ));
1103 s.push_str("</nav>\n");
1104 s.push_str("</div>\n</header>\n");
1105
1106 s.push_str("<main class=\"mc-main\">\n");
1107 s.push_str(body);
1108 s.push_str("</main>\n</body>\n</html>\n");
1109
1110 // Never held. A console page shows the site as it stands, and it stands behind a session --
1111 // so a store keeping it would show one admin's page to whoever asked next on that machine.
1112 cache::generated(
1113 HttpMessage::new_response(HttpStatus::OK)
1114 .with_field(
1115 HeaderName::ContentType,
1116 HeaderFieldValue::Generic("text/html; charset=utf-8".to_string()),
1117 )
1118 .with_body(s.into_bytes())
1119 )
1120}
1121
1122/// What a signed-in member who is not an admin is shown.
1123///
1124/// Two pages in one, told apart by `claimable`. Where the site has no admins at all, this is the
1125/// bootstrap: the member may claim it, and is shown the button that makes them the first admin, POSTing
1126/// the claim with its CSRF token. Where the site already has admins, they are shown their own id, to
1127/// hand to an existing admin who can add them. A page, not a redirect, because there is something here
1128/// for them to read and act on -- which a member sent silently home would never find.
1129fn not_yet_admin(theme: &Theme, username: &str, claimable: bool, csrf: &str) -> HttpMessage {
1130 let body = if claimable {
1131 // No admins yet, so this member may make themselves the first. The button is the whole
1132 // bootstrap: no config edit, no restart, no operator.
1133 fmt!(
1134 "<h1>Claim this site</h1>\n\
1135 <p class=\"mc-muted\">No one administers this site yet. You are signed in, so you can \
1136 claim it and become its first administrator. From there you can add others.</p>\n\
1137 <form method=\"POST\" action=\"{claim}\">\n\
1138 <input type=\"hidden\" name=\"csrf\" value=\"{csrf}\">\n\
1139 <div class=\"mc-actions\">\n\
1140 <button type=\"submit\" class=\"mc-btn\" id=\"mc-claim\">Claim this site as admin</button>\n\
1141 </div>\n\
1142 </form>\n\
1143 <p class=\"mc-muted\">Your account's id is <code>{id}</code>. \
1144 <a href=\"{home}\">Back to the site.</a></p>\n",
1145 claim = PATH_CLAIM,
1146 csrf = html_escape(csrf),
1147 id = html_escape(username),
1148 home = html_escape(&theme.home),
1149 )
1150 } else {
1151 fmt!(
1152 "<h1>Not your site to manage — yet</h1>\n\
1153 <p class=\"mc-muted\">You are signed in, but you are not one of this site's administrators. \
1154 If you should be, give an existing administrator this id and ask to be added:</p>\n\
1155 <p class=\"mc-notice\"><code>{id}</code></p>\n\
1156 <p class=\"mc-muted\">It is not a secret; it is the public name of your account, and knowing \
1157 it does not let anyone sign in as you. <a href=\"{home}\">Back to the site.</a></p>\n",
1158 id = html_escape(username),
1159 home = html_escape(&theme.home),
1160 )
1161 };
1162 // A bare page in the same chrome, but without the admin nav: they are not one, so it would name
1163 // pages they cannot open.
1164 let mut s = String::new();
1165 s.push_str("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n");
1166 s.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
1167 s.push_str("<meta name=\"robots\" content=\"noindex\">\n<title>Manage</title>\n");
1168 for href in &theme.css {
1169 s.push_str("<link rel=\"stylesheet\" href=\"");
1170 s.push_str(&html_escape(href));
1171 s.push_str("\">\n");
1172 }
1173 s.push_str("<style>\n");
1174 s.push_str(CONSOLE_CSS);
1175 s.push_str("</style>\n</head>\n<body class=\"mc-body\">\n<main class=\"mc-main\">\n");
1176 s.push_str(&body);
1177 s.push_str("</main>\n</body>\n</html>\n");
1178
1179 cache::generated(HttpMessage::new_response(HttpStatus::Forbidden)
1180 .with_field(
1181 HeaderName::ContentType,
1182 HeaderFieldValue::Generic("text/html; charset=utf-8".to_string()),
1183 )
1184 .with_body(s.into_bytes()))
1185}
1186
1187// The console's own styling, consuming the site's custom properties where they are set.
1188//
1189// Every colour and font falls back to a neutral default, so a site that defines none still gets a
1190// legible page; a site that defines the Elearnity-style tokens gets its own palette. Kept small and
1191// inline: it is chrome for a handful of pages, not a stylesheet worth a request.
1192const CONSOLE_CSS: &str = "\
1193/* The console's surface. \
1194\
1195 `--bg-primary` and `--text-primary` are a PAIR and are read as one: a site that wants the \
1196 console in its own colours sets both, and a site that sets neither gets the two literals below, \
1197 which are known to contrast. What this must never do is take the background from one source and \
1198 the foreground from another -- an earlier version fell through to `--body-bg` and `--body-color` \
1199 independently, and on a site where both name the same colour (because its body text never sits \
1200 on its body background) the whole console rendered in its own background colour and every word \
1201 of it vanished. A contrast bug cannot be seen in a stylesheet; it is only ever seen on a page. */\
1202.mc-body{margin:0;background:var(--bg-primary,#14181d);color:var(--text-primary,#e6e6e6);\
1203font-family:var(--font,var(--font-ui,var(--font-body,system-ui,sans-serif)));line-height:1.5;}\
1204.mc-head{border-bottom:1px solid var(--border,var(--aside-rule-color,#333c47));}\
1205.mc-head-in{max-width:80rem;margin:0 auto;padding:0.9rem 1.2rem;display:flex;\
1206align-items:baseline;justify-content:space-between;gap:1rem;flex-wrap:wrap;}\
1207.mc-brand{font-weight:600;font-size:1.05rem;}\
1208.mc-brand-sub{color:var(--text-secondary,var(--aside-date-color,#8a97a6));font-weight:400;font-size:0.8rem;\
1209text-transform:uppercase;letter-spacing:0.08em;}\
1210.mc-nav{display:flex;align-items:center;gap:1.1rem;font-size:0.9rem;}\
1211.mc-nav a{color:var(--accent,var(--aside-link-color,#7fb0e0));text-decoration:none;}\
1212.mc-nav a:hover{text-decoration:underline;}\
1213.mc-who{color:var(--text-secondary,var(--aside-date-color,#8a97a6));font-family:var(--font-mono,monospace);font-size:0.8rem;}\
1214/* A management screen is tables and side-by-side panes, not prose, so it takes the width it is \
1215 given. Running text inside it is held to a readable measure separately, below. */\
1216.mc-main{max-width:80rem;margin:0 auto;padding:1.4rem 1.2rem 4rem;}\
1217/* The whole heading scale, not just the first rung. Setting h1 alone leaves h2 and h3 to the \
1218 site's own stylesheet, whose scale is built for prose -- and a site whose h2 is larger than \
1219 the console's h1 inverts the hierarchy on every page that has a section in it. */\
1220.mc-main h1{font-size:1.5rem;margin:0 0 0.3rem;}\
1221.mc-main h2{font-size:1.15rem;margin:2rem 0 0.4rem;}\
1222.mc-main h3{font-size:0.95rem;margin:1.4rem 0 0.3rem;\
1223color:var(--text-secondary,var(--aside-date-color,#8a97a6));}\
1224.mc-main h1:first-child,.mc-main h2:first-child{margin-top:0;}\
1225.mc-muted{color:var(--text-secondary,var(--aside-date-color,#8a97a6));font-size:0.9rem;margin:0 0 1.4rem;}\
1226.mc-notice{border:1px solid var(--border,var(--aside-rule-color,#333c47));border-radius:6px;\
1227padding:0.8rem 1rem;margin:0 0 1.2rem;}\
1228.mc-notice code{font-family:var(--font-mono,monospace);font-size:0.85em;}\
1229.mc-notice-err{border-color:#c0554e;color:#d9776f;}\
1230.mc-btn,button.mc-btn{display:inline-block;font:inherit;font-size:0.9rem;cursor:pointer;\
1231padding:0.5rem 0.9rem;border-radius:6px;border:1px solid var(--accent,var(--aside-link-color,#7fb0e0));\
1232background:var(--accent,var(--aside-link-color,#7fb0e0));color:var(--bg-primary,var(--body-bg,#14181d));text-decoration:none;}\
1233.mc-btn:hover{opacity:0.9;text-decoration:none;}\
1234/* The modifiers name the element as well, because the base rule does. `button.mc-btn` outranks \
1235 a bare `.mc-btn-quiet`, so without this every quiet and every dangerous BUTTON -- erase, \
1236 unsubscribe, import, filter -- draws itself as the loud primary one, while the same class on \
1237 an <a> behaves. They looked like three different consoles. */\
1238.mc-btn-quiet,button.mc-btn-quiet{background:transparent;\
1239color:var(--accent,var(--aside-link-color,#7fb0e0));}\
1240.mc-btn-danger,button.mc-btn-danger{background:transparent;border-color:#c0554e;color:#d9776f;}\
1241table.mc-table{width:100%;border-collapse:collapse;margin:0.4rem 0 1.6rem;font-size:0.92rem;}\
1242.mc-table th{text-align:left;font-size:0.75rem;text-transform:uppercase;letter-spacing:0.06em;\
1243color:var(--text-secondary,var(--aside-date-color,#8a97a6));border-bottom:1px solid var(--border,var(--aside-rule-color,#333c47));\
1244padding:0.4rem 0.6rem;}\
1245.mc-table td{border-bottom:1px solid var(--border,var(--aside-rule-color,#333c47));padding:0.55rem 0.6rem;\
1246vertical-align:top;}\
1247.mc-table a{color:var(--accent,var(--aside-link-color,#7fb0e0));text-decoration:none;}\
1248.mc-table a:hover{text-decoration:underline;}\
1249.mc-slug{color:var(--text-secondary,var(--aside-date-color,#8a97a6));font-family:var(--font-mono,monospace);font-size:0.8rem;}\
1250.mc-tag{display:inline-block;font-size:0.72rem;text-transform:uppercase;letter-spacing:0.05em;\
1251padding:0.1rem 0.45rem;border-radius:4px;border:1px solid var(--border,var(--aside-rule-color,#333c47));\
1252color:var(--text-secondary,var(--aside-date-color,#8a97a6));}\
1253.mc-tag-live{border-color:#4f8f57;color:#7bc084;}\
1254.mc-tag-err{border-color:#c0554e;color:#d9776f;}\
1255.mc-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(9rem,1fr));gap:0.8rem;\
1256margin:0.6rem 0 1.4rem;}\
1257.mc-stat{border:1px solid var(--border,var(--aside-rule-color,#333c47));border-radius:6px;\
1258padding:0.8rem 0.9rem;}\
1259.mc-stat-n{font-size:1.7rem;line-height:1.1;color:var(--text,var(--aside-text-color,#d8dee6));}\
1260.mc-stat-k{font-size:0.75rem;text-transform:uppercase;letter-spacing:0.06em;margin-top:0.3rem;\
1261color:var(--accent,var(--aside-link-color,#7fb0e0));}\
1262.mc-stat-note{font-size:0.78rem;margin-top:0.2rem;\
1263color:var(--text-secondary,var(--aside-date-color,#8a97a6));}\
1264.mc-bar{background:var(--border,var(--aside-rule-color,#333c47));border-radius:3px;height:0.5rem;\
1265min-width:6rem;}\
1266.mc-bar-fill{background:var(--accent,var(--aside-link-color,#7fb0e0));border-radius:3px;height:100%;}\
1267/* A page's own title row: the heading on the left, the way out on the right. */\
1268.mc-head-row{display:flex;align-items:center;justify-content:space-between;gap:1rem;margin:0 0 0.6rem;}\
1269.mc-head-row h1{margin:0;}\
1270.mc-head-row .mc-actions{margin-top:0;}\
1271/* The close: an icon, not a word. Same corner on every page that can be left. */\
1272.mc-close{display:inline-flex;align-items:center;justify-content:center;width:1.8rem;height:1.8rem;\
1273border-radius:6px;color:var(--text-secondary,var(--aside-date-color,#8a97a6));text-decoration:none;\
1274border:1px solid transparent;}\
1275.mc-close:hover{color:var(--text,var(--aside-text-color,#d8dee6));\
1276border-color:var(--border,var(--aside-rule-color,#333c47));}\
1277.mc-close svg{width:1.15rem;height:1.15rem;display:block;}\
1278/* A row action: an icon button sized to the row, quiet until pointed at. */\
1279.mc-ico{display:inline-flex;align-items:center;justify-content:center;width:2rem;height:2rem;padding:0;\
1280background:transparent;border:1px solid transparent;border-radius:6px;cursor:pointer;\
1281color:var(--text-secondary,var(--aside-date-color,#8a97a6));text-decoration:none;}\
1282.mc-ico:hover{color:var(--text,var(--aside-text-color,#d8dee6));\
1283border-color:var(--border,var(--aside-rule-color,#333c47));}\
1284.mc-ico-danger:hover{color:#d9776f;border-color:#c0554e;}\
1285.mc-ico svg{width:1.05rem;height:1.05rem;display:block;}\
1286.mc-table .mc-actions{margin-top:0;gap:0.15rem;flex-wrap:nowrap;justify-content:flex-end;}\
1287/* The editor beside its preview: stacked on a narrow screen, side by side where there is room. \
1288 Equal columns, so neither the prose nor its rendering is the afterthought. */\
1289.mc-split{display:grid;grid-template-columns:1fr;gap:1rem;align-items:stretch;}\
1290@media (min-width:60rem){.mc-split{grid-template-columns:1fr 1fr;}}\
1291/* Both panes fill the row, so the prose and its rendering are the same height. Left to \
1292 themselves a textarea takes its rows attribute and the preview takes its content, and the \
1293 two sit side by side at visibly different sizes. */\
1294.mc-pane{min-width:0;display:flex;flex-direction:column;}\
1295.mc-pane label{margin-top:0;}\
1296.mc-pane textarea,.mc-pane .mc-preview{flex:1 1 auto;min-height:26rem;}\
1297.mc-preview{border:1px solid var(--border,var(--aside-rule-color,#333c47));border-radius:6px;\
1298padding:0.9rem 1rem;overflow:auto;\
1299background:var(--bg-secondary,var(--aside-bg,transparent));}\
1300.mc-preview>*:first-child{margin-top:0;}\
1301.mc-preview img{max-width:100%;height:auto;}\
1302/* Filter and pager: the two things a list needs once it stops fitting on a screen. */\
1303.mc-filter{display:flex;gap:0.6rem;align-items:flex-end;flex-wrap:wrap;margin:0 0 1rem;}\
1304.mc-filter label{margin:0 0 0.25rem;}\
1305/* The button stands on the same line as the boxes it acts on, so it is the same height as \
1306 them -- a padding-sized button beside a fixed-height input is a step in the row. */\
1307.mc-filter .mc-btn{height:2.4rem;padding-top:0;padding-bottom:0;display:inline-flex;align-items:center;}\
1308.mc-filter .mc-f-text{flex:1 1 14rem;}\
1309.mc-filter .mc-f-sel{flex:0 0 9rem;}\
1310.mc-pager{display:flex;gap:0.5rem;align-items:center;justify-content:flex-end;margin:0 0 1.5rem;\
1311font-size:0.85rem;color:var(--text-secondary,var(--aside-date-color,#8a97a6));}\
1312.mc-pager a{color:var(--accent,var(--aside-link-color,#7fb0e0));text-decoration:none;\
1313border:1px solid var(--border,var(--aside-rule-color,#333c47));border-radius:6px;padding:0.25rem 0.6rem;}\
1314.mc-pager a:hover{text-decoration:underline;}\
1315.mc-pager .mc-pager-at{padding:0.25rem 0.2rem;}\
1316/* A form is as wide as its longest field wants to be, which for an address is not the page. */\
1317.mc-send .mc-form,.mc-send .mc-notice{max-width:34rem;}\
1318.mc-form label{display:block;font-size:0.8rem;text-transform:uppercase;letter-spacing:0.05em;\
1319color:var(--text-secondary,var(--aside-date-color,#8a97a6));margin:1rem 0 0.3rem;}\
1320.mc-form input[type=text],.mc-form input[type=password],.mc-form input[type=email],.mc-form select,.mc-form textarea{width:100%;box-sizing:border-box;\
1321font:inherit;background:var(--bg-tertiary,var(--input-bg,#0e1216));color:var(--input-color,inherit);\
1322border:1px solid var(--border,var(--aside-rule-color,#333c47));border-radius:6px;padding:0.5rem 0.6rem;}\
1323/* One height for every control on a row. A select carries its own intrinsic height and a text \
1324input another, so a row of them steps up and down unless both are told the same number. */\
1325.mc-form input[type=text],.mc-form input[type=password],.mc-form input[type=email],.mc-form select{\
1326height:2.4rem;line-height:normal;padding-top:0;padding-bottom:0;}\
1327.mc-form textarea{min-height:22rem;font-family:var(--font-mono,monospace);font-size:0.9rem;\
1328line-height:1.5;resize:vertical;}\
1329.mc-row{display:flex;gap:1rem;flex-wrap:wrap;}\
1330.mc-row>div{flex:1 1 8rem;}\
1331.mc-actions{margin-top:1.2rem;display:flex;gap:0.7rem;align-items:center;flex-wrap:wrap;}\
1332/* The autosave line where Save used to be: quiet, a little louder mid-save, red only when it \
1333needs an eye. `tabular-nums` keeps the count from nudging the line as it grows. */\
1334.mc-autosave{font-size:0.85rem;letter-spacing:0.02em;\
1335color:var(--text-secondary,var(--aside-date-color,#8a97a6));\
1336font-variant-numeric:tabular-nums;transition:color 120ms ease,opacity 120ms ease;}\
1337.mc-autosave.is-working{color:var(--text-primary,#e6e6e6);}\
1338.mc-autosave.is-error{color:#e57373;font-weight:600;}\
1339/* Prose keeps a readable measure even where the page around it is wide. */\
1340.mc-prose{max-width:40rem;border:1px solid var(--border,var(--aside-rule-color,#333c47));border-radius:6px;padding:1.2rem 1.4rem;\
1341margin-top:0.8rem;}\
1342/* A settings form keeps a measure too. The page is 80rem because it holds tables and\
1343 side-by-side panes; a field for a handle or a host is not made better by being 80rem\
1344 of it, and a row of them that wide reads as unconsidered rather than spacious. */\
1345.mc-settings{max-width:34rem;}\
1346/* The Fix button sits with the Text label, quiet, above the editor. */\
1347.mc-pane-head{display:flex;align-items:baseline;justify-content:space-between;gap:0.6rem;}\
1348.mc-fix-btn{flex:0 0 auto;}\
1349/* The suggestion panel: the model's proposed text, shown as a line diff against the author's own so \
1350 a copy-edit is a change to see, not a wall to re-read. */\
1351.mc-fix-panel{margin:1rem 0;padding:1rem;border:1px solid var(--border,var(--aside-rule-color,#333c47));\
1352border-radius:6px;}\
1353.mc-fix-head{margin-bottom:0.6rem;}\
1354.mc-fix-diff{font-family:var(--font-mono,monospace);font-size:0.9rem;line-height:1.5;\
1355white-space:pre-wrap;word-break:break-word;max-height:24rem;overflow-y:auto;}\
1356.mc-fix-diff div{white-space:pre-wrap;}\
1357.mc-fix-diff ins{background:rgba(76,154,106,0.22);text-decoration:none;display:block;}\
1358.mc-fix-diff del{background:rgba(192,85,78,0.20);text-decoration:line-through;display:block;opacity:0.8;}\
1359.mc-fix-plain{white-space:pre-wrap;margin:0;}\
1360/* A settings textarea is a prompt or a short list, not the 22rem post editor, so it honours its own \
1361 `rows` and reads as prose rather than code. */\
1362.mc-settings textarea{min-height:0;font-family:inherit;font-size:0.95rem;line-height:1.5;}\
1363/* A hint under a field: quiet, and it does not want the field's own bottom margin doubled. */\
1364.mc-note{display:block;margin:0.25rem 0 0;font-size:0.82rem;\
1365color:var(--text-secondary,var(--aside-date-color,#8a97a6));}\
1366/* A test result reads its outcome by colour: the model answered, or it did not. */\
1367.mc-note.mc-ok{color:#5fae5f;}\
1368.mc-note.mc-err{color:#d9776f;}\
1369/* The moderation queue. A comment is shown rendered, framed, with its verbs beneath: a\
1370 decision about what to publish is made looking at what would be published. */\
1371.mc-comment{border:1px solid var(--border,var(--aside-rule-color,#333c47));border-radius:6px;\
1372padding:0.9rem 1.1rem;margin:0.8rem 0;}\
1373.mc-comment-by{font-size:0.92rem;margin-bottom:0.2rem;}\
1374/* The post a comment is on, in the console's own link colour: without this the anchor falls\
1375 through to the browser default blue, which no other link on any console page wears. */\
1376.mc-comment-by a{color:var(--accent,var(--aside-link-color,#7fb0e0));text-decoration:none;}\
1377.mc-comment-by a:hover{text-decoration:underline;}\
1378.mc-comment-why{font-size:0.85rem;margin:0.1rem 0 0.4rem;}\
1379.mc-comment-body{margin:0.5rem 0 0.7rem;}\
1380.mc-comment-acts{display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center;}\
1381.mc-inline{display:inline;}\
1382/* The site's comments switch, above the queue: the state, the control, and what it will do. */\
1383.mc-switch{display:flex;align-items:center;gap:0.8rem;flex-wrap:wrap;margin:0.6rem 0 1.2rem;\
1384padding:0.8rem 1rem;border:1px solid var(--border,var(--aside-rule-color,#333c47));border-radius:6px;}\
1385.mc-switch-state{font-size:0.95rem;}\
1386.mc-tag{display:inline-block;font-size:0.72rem;text-transform:uppercase;letter-spacing:0.06em;\
1387padding:0.1rem 0.4rem;border-radius:4px;border:1px solid var(--border,#333c47);opacity:0.75;}\
1388.mc-tag-live{border-color:#4c9a6a;color:#7fc79b;opacity:1;}\
1389.mc-tag-err{border-color:#c0554e;color:#d9776f;opacity:1;}\
1390.mc-settings + .mc-settings{margin-top:0.6rem;}\
1391.mc-author{display:flex;align-items:center;gap:0.5rem;font-size:0.85rem;\
1392color:var(--text-secondary,var(--aside-date-color,#8a97a6));}\
1393/* A line of its own at the foot of the field row. It is a note about the post rather than a field \
1394of it, and sharing the row's `flex:1 1 8rem` with five fields crushed it into a column two words \
1395wide -- the label wrapped, and the control wrapped inside that. */\
1396.mc-row>.mc-author{flex:1 0 100%;margin-top:0.2rem;}\
1397.mc-author-lbl,.mc-author .mc-btn{white-space:nowrap;}\
1398.mc-author-lbl{text-transform:uppercase;letter-spacing:0.05em;font-size:0.72rem;}\
1399.mc-author-name{color:var(--text-primary,#e6e6e6);font-weight:600;}\
1400/* The tick in the site's own colour rather than the browser's default blue-or-red, which lands \
1401in whatever palette the site set and belongs to none of them. */\
1402.mc-form input[type=checkbox]{accent-color:var(--accent,\
1403var(--col-teal,#4c8bf5));width:0.95rem;height:0.95rem;margin:0;flex:none;}\
1404/* Categories and tags are the one widget twice, so they take the one set of rules. Nothing inside \
1405either is a `<label>` -- the box names are spans and the chips are buttons -- which is deliberate: \
1406`.mc-form label` has specificity (0,1,1) and would beat any bare class here, forcing `display:block` \
1407(killing the flex `gap`) and shouting the value in uppercase. A category is not a field name. Where \
1408a rule below must reach a `<label>`, it is named through `.mc-form` to outrank it; source order \
1409would not settle it, since both rules sit in this one sheet. */\
1410.mc-cats-field,.mc-tags-field{margin:0.2rem 0 0.9rem;}\
1411.mc-cats-boxes,.mc-tags-boxes{display:grid;grid-template-columns:1fr 1fr;gap:0.8rem;margin:0.3rem 0 0;}\
1412.mc-catbox,.mc-tagbox{min-width:0;}\
1413.mc-catbox-lbl,.mc-tagbox-lbl{display:block;font-size:0.72rem;text-transform:uppercase;\
1414letter-spacing:0.06em;margin:0 0 0.35rem;\
1415color:var(--text-secondary,var(--aside-date-color,#8a97a6));}\
1416.mc-tags-search{width:100%;box-sizing:border-box;font:inherit;font-size:0.85rem;\
1417padding:0.35rem 0.55rem;margin:0 0 0.4rem;border-radius:6px;\
1418border:1px solid var(--border,var(--aside-rule-color,#333c47));background:transparent;\
1419color:var(--text-primary,#e6e6e6);}\
1420.mc-chips{display:flex;flex-wrap:wrap;gap:0.4rem;align-content:flex-start;min-height:2.6rem;\
1421padding:0.45rem;border-radius:6px;border:1px dashed var(--border,var(--aside-rule-color,#333c47));}\
1422.mc-chips.mc-drop{border-style:solid;border-color:var(--accent,var(--aside-link-color,#7fb0e0));}\
1423/* A chip -- the console's twin of the reader's, so the two consoles and both blogs draw the one \
1424thing. A fixed height, a closer square filling the right cap, the cross on its centre. */\
1425.mc-chip{--chip-h:1.55rem;position:relative;box-sizing:border-box;display:inline-flex;\
1426align-items:center;gap:0.25rem;height:var(--chip-h);font:inherit;font-size:0.82rem;\
1427cursor:pointer;padding:0 0.6rem;border-radius:999px;user-select:none;\
1428border:1px solid var(--border,var(--aside-rule-color,#333c47));background:transparent;\
1429color:var(--text-primary,#e6e6e6);white-space:nowrap;}\
1430.mc-chip:hover{border-color:var(--accent,var(--aside-link-color,#7fb0e0));}\
1431.mc-chips-selected .mc-chip{padding-right:var(--chip-h);\
1432background:var(--accent,var(--aside-link-color,#3b6ea5));\
1433border-color:var(--accent,var(--aside-link-color,#3b6ea5));color:#fff;}\
1434/* The closer: a square filling the padding-box height, so the cross sits on the cap centre \
1435regardless of the border; the glyph hidden and two bars drawn in its place. */\
1436.mc-chip-x{position:absolute;right:0;top:0;height:100%;aspect-ratio:1;font-size:0;opacity:0.75;}\
1437.mc-chip-x::before,.mc-chip-x::after{content:\"\";position:absolute;left:50%;top:50%;\
1438width:calc(var(--chip-h)*0.34);height:1.4px;border-radius:2px;background:currentColor;}\
1439.mc-chip-x::before{transform:translate(-50%,-50%) rotate(45deg);}\
1440.mc-chip-x::after{transform:translate(-50%,-50%) rotate(-45deg);}\
1441/* The curator's delete-from-vocabulary mark is a distinct, rarer power -- kept a glyph, and red, \
1442so it never reads as the ordinary remove-from-post closer. */\
1443.mc-chip-del{font-size:0.95rem;line-height:1;opacity:0.75;color:#e57373;}\
1444.mc-chip:hover .mc-chip-x,.mc-chip:hover .mc-chip-del{opacity:1;}\
1445.mc-avatar-row{margin:0 0 1rem;display:flex;align-items:center;gap:1rem;flex-wrap:wrap;}\
1446.mc-avatar-pick{display:flex;flex-direction:column;align-items:flex-start;gap:0.4rem;}\
1447.mc-avatar-pick .mc-btn-quiet{cursor:pointer;}\
1448/* The description is prose, not source, so it takes the page's own font and a few lines rather \
1449 than the tall monospace box an editor wants. */\
1450.mc-form textarea#bio{min-height:0;font-family:inherit;font-size:0.95rem;}\
1451.mc-avatar-pic,.mc-avatar-initial{width:4rem;height:4rem;border-radius:50%;object-fit:cover;\
1452display:inline-flex;align-items:center;justify-content:center;font-size:1.6rem;font-weight:600;\
1453color:#fff;background:var(--accent,var(--aside-link-color,#3b6ea5));}\
1454.mc-hint{font-size:0.8rem;color:var(--text-secondary,var(--aside-date-color,#8a97a6));margin:0.3rem 0 0;}\
1455@media (max-width:32rem){.mc-cats-boxes,.mc-tags-boxes{grid-template-columns:1fr;}}\
1456";
1457
1458
1459// ┌───────────────────────────────────────────────────────────────────────────┐
1460// │ HELPERS │
1461// └───────────────────────────────────────────────────────────────────────────┘
1462
1463/// The status answer: whether the asker may manage this site, and if so the token their writes need.
1464///
1465/// The token is safe to hand out here: it proves a request came from a page that holds the session,
1466/// and only a caller with the session cookie reaches this with `admin` true. A cross-site page has
1467/// neither the cookie (it is `SameSite=Lax`, unsent on a cross-site request) nor the reply (the
1468/// same-origin policy hides it), so it learns nothing.
1469fn status_json(
1470 admin: bool,
1471 claimable: bool,
1472 csrf: Option<&str>,
1473 dests: &[&str],
1474 categories: &[String],
1475)
1476 -> HttpMessage
1477{
1478 // The destinations the site can offer, as a JSON array. The words are a fixed vocabulary
1479 // (`Destination::as_str`), so they need no escaping.
1480 let items: Vec<String> = dests.iter().map(|d| fmt!("\"{}\"", d)).collect();
1481 let dest_arr = fmt!("[{}]", items.join(","));
1482 // The categories, as a JSON array. Unlike the destinations these are free config strings, so each
1483 // is escaped for a JSON string literal -- a quote or a backslash in a category name must not break
1484 // the document.
1485 let cat_items: Vec<String> = categories.iter().map(|c| {
1486 let mut s = String::from("\"");
1487 for ch in c.chars() {
1488 match ch {
1489 '"' => s.push_str("\\\""),
1490 '\\' => s.push_str("\\\\"),
1491 c if (c as u32) < 0x20 => s.push_str(&fmt!("\\u{:04x}", c as u32)),
1492 c => s.push(c),
1493 }
1494 }
1495 s.push('"');
1496 s
1497 }).collect();
1498 let cat_arr = fmt!("[{}]", cat_items.join(","));
1499 let body = match csrf {
1500 Some(t) => fmt!(
1501 "{{\"admin\":{},\"claimable\":{},\"csrf\":\"{}\",\"destinations\":{},\"categories\":{}}}",
1502 admin, claimable, t, dest_arr, cat_arr),
1503 None => fmt!(
1504 "{{\"admin\":{},\"claimable\":{},\"destinations\":{},\"categories\":{}}}",
1505 admin, claimable, dest_arr, cat_arr),
1506 };
1507 // Never held: this carries the CSRF token, and a stale one fails every write that uses it.
1508 cache::generated(HttpMessage::new_response(HttpStatus::OK)
1509 .with_field(
1510 HeaderName::ContentType,
1511 HeaderFieldValue::Generic("application/json".to_string()),
1512 )
1513 .with_body(body.into_bytes()))
1514}
1515
1516pub fn redirect(to: &str) -> HttpMessage {
1517 HttpMessage::new_response(HttpStatus::SeeOther)
1518 .with_field(
1519 HeaderName::Location,
1520 HeaderFieldValue::Generic(to.to_string()),
1521 )
1522}
1523
1524fn json_ok() -> HttpMessage {
1525 cache::generated(HttpMessage::new_response(HttpStatus::OK)
1526 .with_field(
1527 HeaderName::ContentType,
1528 HeaderFieldValue::Generic("application/json".to_string()),
1529 )
1530 .with_body("{\"ok\":true}".to_string().into_bytes()))
1531}
1532
1533/// A plain JSON error a fetch caller can read, its reason escaped for a string literal.
1534fn json_err(why: &str) -> HttpMessage {
1535 cache::generated(HttpMessage::new_response(HttpStatus::OK)
1536 .with_field(
1537 HeaderName::ContentType,
1538 HeaderFieldValue::Generic("application/json".to_string()),
1539 )
1540 .with_body(fmt!("{{\"error\":\"{}\"}}", json_escape(why)).into_bytes()))
1541}
1542
1543/// A CSRF refusal, in the shape the caller asked for: JSON for a fetch, a redirect home for a form.
1544///
1545/// The same answer the post console gives, so a stale session fails one way wherever it is presented.
1546fn csrf_deny(json: bool) -> HttpMessage {
1547 if json {
1548 cache::generated(HttpMessage::new_response(HttpStatus::Forbidden)
1549 .with_field(
1550 HeaderName::ContentType,
1551 HeaderFieldValue::Generic("application/json".to_string()),
1552 )
1553 .with_body("{\"error\":\"stale session; reload\"}".to_string().into_bytes()))
1554 } else {
1555 redirect(PATH_ROOT)
1556 }
1557}
1558
1559/// A claim that did not go through: the reason for a fetch caller, or the manage page again for a form,
1560/// where the member lands back on the not-yet-admin view.
1561fn claim_deny(json: bool, why: &str) -> HttpMessage {
1562 if json {
1563 json_err(why)
1564 } else {
1565 redirect(PATH_ROOT)
1566 }
1567}
1568
1569/// An admin-management write that did not go through: the reason for a fetch caller, or the admins page
1570/// again for a form, carrying the reason in the query it lands with.
1571fn admins_deny(json: bool, why: &str) -> HttpMessage {
1572 if json {
1573 json_err(why)
1574 } else {
1575 redirect(&fmt!("{}?said={}", PATH_ADMINS, query_encode(why)))
1576 }
1577}
1578
1579/// The `said` field out of a raw query substring, url-decoded, so the admins page can show why a write
1580/// was refused.
1581fn said_field(query: &str) -> Option<String> {
1582 for pair in query.split('&') {
1583 let mut kv = pair.splitn(2, '=');
1584 let k = match kv.next() {
1585 Some(k) => k,
1586 None => continue,
1587 };
1588 let v = kv.next().unwrap_or("");
1589 if k == "said" {
1590 let val = form_decode(v);
1591 if val.is_empty() {
1592 return None;
1593 }
1594 return Some(val);
1595 }
1596 }
1597 None
1598}
1599
1600/// Escapes a string for a JSON string literal: the two characters that would break out of one.
1601///
1602/// Enough for the reasons put through it, which are prose and the odd id-hash, never a control
1603/// character.
1604fn json_escape(s: &str) -> String {
1605 s.replace('\\', "\\\\").replace('"', "\\\"")
1606}
1607
1608/// Percent-encodes a string for a query parameter, per RFC 3986 section 2.3.
1609fn query_encode(s: &str) -> String {
1610 let mut out = String::with_capacity(s.len());
1611 for b in s.as_bytes() {
1612 match *b {
1613 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9'
1614 | b'-' | b'_' | b'.' | b'~' => out.push(*b as char),
1615 other => out.push_str(&fmt!("%{:02X}", other)),
1616 }
1617 }
1618 out
1619}
1620
1621/// The token a form must carry back, derived from the session it was rendered for.
1622///
1623/// A value only a page that held the session cookie could have been handed: the cookie is `HttpOnly`,
1624/// so no script reads the session id, and the token is a one-way function of it, so no one derives
1625/// the token without it. A forged cross-site POST has neither.
1626pub fn csrf_token(sid: &str) -> String {
1627 let h = HashScheme::new_sha3_256().hash(&[sid.as_bytes(), CSRF_DOMAIN], []);
1628 hex(&h.as_hashform().as_vec())
1629}
1630
1631/// Whether a token a form sent back matches the session the cookie names.
1632///
1633/// A plain comparison, and it can be: the token is not a secret to keep from timing, it is a value an
1634/// honest client already holds and a forger cannot compute. What it proves is provenance, not
1635/// identity -- the cookie proves identity.
1636fn csrf_ok(sid: &str, sent: &str) -> bool {
1637 !sent.is_empty() && sent == csrf_token(sid)
1638}
1639
1640// The domain-separator for the CSRF hash, so the token can never be some other digest of the same
1641// session put to a different use.
1642const CSRF_DOMAIN: &[u8] = b"steel-site-console-csrf-v1";
1643
1644fn hex(bytes: &[u8]) -> String {
1645 let mut s = String::with_capacity(bytes.len() * 2);
1646 for b in bytes {
1647 s.push_str(&fmt!("{:02x}", b));
1648 }
1649 s
1650}
1651
1652/// One field out of an `x-www-form-urlencoded` body.
1653///
1654/// The console's own reader, so the console does not lean on the operator dashboard's -- the two
1655/// tiers share as little as they can, and a form field parser is not worth coupling them over.
1656pub fn form_field(body: &[u8], key: &str) -> Option<String> {
1657 let s = match std::str::from_utf8(body) {
1658 Ok(s) => s,
1659 Err(_) => return None,
1660 };
1661 for pair in s.split('&') {
1662 let mut kv = pair.splitn(2, '=');
1663 let k = match kv.next() {
1664 Some(k) => k,
1665 None => continue,
1666 };
1667 let v = kv.next().unwrap_or("");
1668 if form_decode(k) == key {
1669 return Some(form_decode(v));
1670 }
1671 }
1672 None
1673}
1674
1675/// Decode an `x-www-form-urlencoded` value: `+` is a space, `%XX` a byte, a bad escape itself.
1676fn form_decode(s: &str) -> String {
1677 let b = s.as_bytes();
1678 let mut out = Vec::with_capacity(b.len());
1679 let mut i = 0;
1680 while i < b.len() {
1681 match b[i] {
1682 b'+' => {
1683 out.push(b' ');
1684 i += 1;
1685 }
1686 b'%' if i + 2 < b.len() => {
1687 match (nibble(b[i + 1]), nibble(b[i + 2])) {
1688 (Some(hi), Some(lo)) => {
1689 out.push((hi << 4) | lo);
1690 i += 3;
1691 }
1692 _ => {
1693 out.push(b[i]);
1694 i += 1;
1695 }
1696 }
1697 }
1698 c => {
1699 out.push(c);
1700 i += 1;
1701 }
1702 }
1703 }
1704 String::from_utf8_lossy(&out).into_owned()
1705}
1706
1707fn nibble(b: u8) -> Option<u8> {
1708 match b {
1709 b'0'..=b'9' => Some(b - b'0'),
1710 b'a'..=b'f' => Some(b - b'a' + 10),
1711 b'A'..=b'F' => Some(b - b'A' + 10),
1712 _ => None,
1713 }
1714}
1715
1716
1717#[cfg(test)]
1718mod tests {
1719 use super::*;
1720
1721 /// The console answers for its own prefix and nothing that merely starts like it.
1722 #[test]
1723 fn test_owns_its_prefix_00() -> Outcome<()> {
1724 assert!(owns("/manage"));
1725 assert!(owns("/manage/edit"));
1726 assert!(owns("/manage/status"));
1727 assert!(!owns("/manageable"));
1728 assert!(!owns("/admin"));
1729 assert!(!owns("/"));
1730 Ok(())
1731 }
1732
1733 /// A token is a function of the session, so it holds for that session and no other.
1734 #[test]
1735 fn test_a_token_is_bound_to_its_session_01() -> Outcome<()> {
1736 let a = csrf_token("session-aaa");
1737 let b = csrf_token("session-bbb");
1738 assert_ne!(a, b, "two sessions produced the same token");
1739 assert!(csrf_ok("session-aaa", &a));
1740 assert!(!csrf_ok("session-aaa", &b), "another session's token passed");
1741 assert!(!csrf_ok("session-aaa", ""), "an empty token passed");
1742 assert_eq!(a.len(), 64, "a sha3-256 token is 64 hex characters");
1743 Ok(())
1744 }
1745
1746 /// A form field survives the shapes a browser sends it in.
1747 #[test]
1748 fn test_a_form_field_is_read_02() -> Outcome<()> {
1749 assert_eq!(form_field(b"slug=on-rent", "slug"), Some(fmt!("on-rent")));
1750 assert_eq!(form_field(b"a=1&slug=on-rent&b=2", "slug"), Some(fmt!("on-rent")));
1751 assert_eq!(form_field(b"slug=a%20b", "slug"), Some(fmt!("a b")));
1752 assert_eq!(form_field(b"date=2026-07-17+14%3A30", "date"), Some(fmt!("2026-07-17 14:30")));
1753 assert_eq!(form_field(b"other=1", "slug"), None);
1754 Ok(())
1755 }
1756
1757 /// The effective admin set is the config list unioned with the database one: config first, database
1758 /// names that do not repeat one added, and no id twice.
1759 #[test]
1760 fn test_the_effective_admins_are_the_union_03() -> Outcome<()> {
1761 let cfg = vec![fmt!("aaa"), fmt!("bbb")];
1762 let db = vec![fmt!("bbb"), fmt!("ccc")];
1763 // Config first, then the database's new one, and the shared id once.
1764 assert_eq!(union_admins(&cfg, &db), vec![fmt!("aaa"), fmt!("bbb"), fmt!("ccc")]);
1765 // Either set alone is itself.
1766 assert_eq!(union_admins(&cfg, &[]), cfg);
1767 assert_eq!(union_admins(&[], &db), db);
1768 // Two empty sets is empty -- which is the claimable state.
1769 assert!(union_admins(&[], &[]).is_empty());
1770 Ok(())
1771 }
1772
1773 /// An id-hash is 64 lowercase hex characters, and nothing else passes the gate a grant goes through.
1774 #[test]
1775 fn test_an_id_hash_is_sixty_four_lower_hex_04() -> Outcome<()> {
1776 let good = "0123456789abcdef".repeat(4);
1777 assert_eq!(good.len(), 64);
1778 assert!(valid_id_hash(&good));
1779 // Too short, too long.
1780 assert!(!valid_id_hash(&"ab".repeat(31))); // 62
1781 assert!(!valid_id_hash(&"ab".repeat(33))); // 66
1782 // Uppercase is not lowercase.
1783 assert!(!valid_id_hash(&"AB".repeat(32)));
1784 // A non-hex character in an otherwise sound length.
1785 let mut bad = "a".repeat(63);
1786 bad.push('z');
1787 assert!(!valid_id_hash(&bad));
1788 // Empty is not a hash.
1789 assert!(!valid_id_hash(""));
1790 Ok(())
1791 }
1792
1793 // ┌───────────────────────────────────────────────────────────────────────────┐
1794 // │ PASSPHRASE SIGN-IN │
1795 // └───────────────────────────────────────────────────────────────────────────┘
1796
1797 use oxedyne_fe2o3_crypto::keystore::{
1798 Wallet,
1799 DEFAULT_WALLET_KDF_NAME,
1800 };
1801 use oxedyne_fe2o3_net::http::header::HttpHeadline;
1802 use secrecy::ExposeSecret;
1803
1804 /// The database type the gate is instantiated over. The gate never touches it
1805 /// on the passphrase path -- the manage session is proven before any database
1806 /// is consulted -- so the tests pass `None` and only need the type named.
1807 type TestDb = oxedyne_fe2o3_o3db_sync::O3db<
1808 { crate::srv::id::UID_LEN },
1809 crate::srv::id::Uid,
1810 oxedyne_fe2o3_crypto::enc::EncryptionScheme,
1811 oxedyne_fe2o3_hash::hash::HashScheme,
1812 oxedyne_fe2o3_hash::hash::HashScheme,
1813 oxedyne_fe2o3_hash::csum::ChecksumScheme,
1814 >;
1815
1816 /// An unsealed admin state around a fresh wallet whose one admin is
1817 /// `name`/`pass`, holding the wildcard scope a first admin gets.
1818 fn mkstate(name: &str, pass: &[u8]) -> Outcome<AdminState> {
1819 let (wallet, unlocked) = res!(Wallet::create_with_first_admin(
1820 oxedyne_fe2o3_jdat::map::DaticleMap::new(),
1821 name,
1822 pass,
1823 DEFAULT_WALLET_KDF_NAME,
1824 ));
1825 let master = unlocked.master_key.expose_secret().clone();
1826 AdminState::new(
1827 Arc::new(RwLock::new(wallet)),
1828 std::path::PathBuf::from("./wallet.jdat"),
1829 Some(master),
1830 1,
1831 None,
1832 crate::srv::admin::traffic::TrafficRecorder::new_shared(0),
1833 crate::srv::admin::host_sampler::HostSampler::new_shared(),
1834 res!(crate::srv::admin::guard::new_shared()),
1835 res!(crate::srv::admin::guard::new_shared()),
1836 Vec::new(),
1837 None,
1838 )
1839 }
1840
1841 fn peer() -> SocketAddr {
1842 SocketAddr::from(([127, 0, 0, 1], 0))
1843 }
1844
1845 fn no_headers() -> Arc<HeaderFields> {
1846 Arc::new(HeaderFields::default())
1847 }
1848
1849 fn cookie_headers(key: &str, val: &str) -> Arc<HeaderFields> {
1850 let mut h = HeaderFields::default();
1851 h.insert(
1852 HeaderName::Cookie,
1853 HeaderFieldValue::Cookie(vec![Cookie {
1854 key: key.to_string(),
1855 val: val.to_string(),
1856 attrs: None,
1857 }]),
1858 None,
1859 );
1860 Arc::new(h)
1861 }
1862
1863 fn json_headers(manage: &str) -> Arc<HeaderFields> {
1864 let mut h = HeaderFields::default();
1865 h.insert(
1866 HeaderName::Accept,
1867 HeaderFieldValue::Generic("application/json".to_string()),
1868 None,
1869 );
1870 if !manage.is_empty() {
1871 h.insert(
1872 HeaderName::Cookie,
1873 HeaderFieldValue::Cookie(vec![Cookie {
1874 key: session::MANAGE_COOKIE_NAME.to_string(),
1875 val: manage.to_string(),
1876 attrs: None,
1877 }]),
1878 None,
1879 );
1880 }
1881 Arc::new(h)
1882 }
1883
1884 /// The gate over the passphrase path, with the DB type named and no database.
1885 fn gate(state: Option<&AdminState>, headers: &Arc<HeaderFields>) -> Outcome<Option<SiteAdmin>> {
1886 site_admin::<
1887 { crate::srv::id::UID_LEN },
1888 crate::srv::id::Uid,
1889 oxedyne_fe2o3_crypto::enc::EncryptionScheme,
1890 oxedyne_fe2o3_hash::hash::HashScheme,
1891 TestDb,
1892 >(&[], state, None, headers)
1893 }
1894
1895 fn status_of(resp: &HttpMessage) -> Option<HttpStatus> {
1896 match &resp.header.headline {
1897 HttpHeadline::Response { status } => Some(*status),
1898 _ => None,
1899 }
1900 }
1901
1902 fn set_cookie_of(resp: &HttpMessage) -> Option<Cookie> {
1903 match resp.header.fields.get_one(&HeaderName::SetCookie) {
1904 Some(HeaderFieldValue::SetCookie(c)) => Some(c.clone()),
1905 _ => None,
1906 }
1907 }
1908
1909 /// A valid passphrase mints a manage session the gate then accepts as an
1910 /// admin, and the cookie it rides in is scoped and hardened for the console.
1911 #[test]
1912 fn test_a_good_passphrase_is_a_gate_admin_05() -> Outcome<()> {
1913 let state = res!(mkstate("jason", b"correct horse"));
1914
1915 // The form sign-in: a browser posting `passphrase=...`.
1916 let resp = do_login(Some(&state), None, &no_headers(), b"passphrase=correct+horse", peer(), "t");
1917 assert_eq!(status_of(&resp), Some(HttpStatus::SeeOther), "a form login redirects on success");
1918
1919 // The cookie it set: named for the console, scoped to reach it, hardened.
1920 let cookie = match set_cookie_of(&resp) {
1921 Some(c) => c,
1922 None => return Err(err!("the login set no cookie"; Invalid)),
1923 };
1924 assert_eq!(cookie.key, session::MANAGE_COOKIE_NAME);
1925 let attrs = match cookie.attrs.clone() {
1926 Some(a) => a,
1927 None => return Err(err!("the manage cookie carried no attributes"; Invalid)),
1928 };
1929 assert!(attrs.contains(&SetCookieAttributes::Path("/".to_string())),
1930 "the manage cookie must be Path=/ to reach /manage");
1931 assert!(!attrs.contains(&SetCookieAttributes::Path("/admin".to_string())),
1932 "the manage cookie must not be scoped to the operator dashboard");
1933 assert!(attrs.contains(&SetCookieAttributes::HttpOnly));
1934 assert!(attrs.contains(&SetCookieAttributes::Secure));
1935 assert!(attrs.contains(&SetCookieAttributes::SameSite(SameSite::Strict)));
1936
1937 // A request bearing that cookie is a site admin at the gate.
1938 let headers = cookie_headers(session::MANAGE_COOKIE_NAME, &cookie.val);
1939 let admin = match res!(gate(Some(&state), &headers)) {
1940 Some(a) => a,
1941 None => return Err(err!("the gate refused a valid manage session"; Invalid)),
1942 };
1943 assert_eq!(admin.username, fmt!("jason"), "the session named the wrong admin");
1944 Ok(())
1945 }
1946
1947 /// A wrong passphrase mints no session: the login sets no cookie, and a
1948 /// request with none is no admin.
1949 #[test]
1950 fn test_a_bad_passphrase_is_no_session_06() -> Outcome<()> {
1951 let state = res!(mkstate("jason", b"correct horse"));
1952
1953 let resp = do_login(Some(&state), None, &no_headers(), b"passphrase=wrong", peer(), "t");
1954 assert_eq!(status_of(&resp), Some(HttpStatus::OK), "a failed form login re-renders the form");
1955 assert!(set_cookie_of(&resp).is_none(), "a failed login must set no session cookie");
1956 let form = resp.body_as_string();
1957 assert!(form.contains("name=\"passphrase\""), "the re-rendered page is the login form");
1958
1959 // The verify itself refuses it.
1960 match res!(auth::verify_passphrase(&state, b"wrong", peer())) {
1961 LoginOutcome::BadCredentials => {}
1962 other => return Err(err!(
1963 "a wrong passphrase did not yield BadCredentials: {:?}", other; Invalid)),
1964 }
1965
1966 // And no cookie means no admin.
1967 assert!(res!(gate(Some(&state), &no_headers())).is_none(), "an unsigned request was an admin");
1968 Ok(())
1969 }
1970
1971 /// A forged or garbage manage cookie is refused by the gate, not read as a
1972 /// session.
1973 #[test]
1974 fn test_a_forged_manage_cookie_is_rejected_07() -> Outcome<()> {
1975 let state = res!(mkstate("jason", b"correct horse"));
1976
1977 for junk in ["not-a-cookie", "m1.zzzz", "m1.", ""] {
1978 let headers = cookie_headers(session::MANAGE_COOKIE_NAME, junk);
1979 assert!(res!(gate(Some(&state), &headers)).is_none(),
1980 "the gate accepted a forged manage cookie '{}'", junk);
1981 }
1982
1983 // A real session with a flipped byte no longer authenticates.
1984 let good = res!(session::encode(&state, "jason"));
1985 let mut bytes = good.into_bytes();
1986 let idx = bytes.len() - 4;
1987 bytes[idx] ^= 0x01;
1988 let tampered = String::from_utf8_lossy(&bytes).into_owned();
1989 let headers = cookie_headers(session::MANAGE_COOKIE_NAME, &tampered);
1990 assert!(res!(gate(Some(&state), &headers)).is_none(), "a tampered manage cookie passed the gate");
1991 Ok(())
1992 }
1993
1994 /// The fetch shapes the front-end is built against: `{"ok":true}` and a
1995 /// cookie on success, `{"ok":false,...}` and no cookie on failure.
1996 #[test]
1997 fn test_the_json_login_shapes_08() -> Outcome<()> {
1998 let state = res!(mkstate("jason", b"correct horse"));
1999
2000 let ok = do_login(Some(&state), None, &json_headers(""), b"passphrase=correct+horse", peer(), "t");
2001 assert_eq!(status_of(&ok), Some(HttpStatus::OK));
2002 assert!(ok.body_as_string().contains("\"ok\":true"), "a JSON success is {{\"ok\":true}}");
2003 assert!(set_cookie_of(&ok).is_some(), "a JSON success still sets the session cookie");
2004
2005 let bad = do_login(Some(&state), None, &json_headers(""), b"passphrase=wrong", peer(), "t");
2006 let body = bad.body_as_string();
2007 assert!(body.contains("\"ok\":false"), "a JSON failure is {{\"ok\":false,...}}");
2008 assert!(body.contains("\"error\":"), "a JSON failure carries an error string");
2009 assert!(set_cookie_of(&bad).is_none(), "a JSON failure sets no cookie");
2010 Ok(())
2011 }
2012
2013 /// `/manage/status` reports `admin:true` and hands out a CSRF token for a
2014 /// passphrase-authed session, so the console's write forms work.
2015 #[tokio::test]
2016 async fn test_status_admin_and_csrf_for_a_passphrase_session_09() -> Outcome<()> {
2017 let state = res!(mkstate("jason", b"correct horse"));
2018 let value = res!(session::encode(&state, "jason"));
2019 let headers = cookie_headers(session::MANAGE_COOKIE_NAME, &value);
2020
2021 let resp = res!(handle_get::<
2022 { crate::srv::id::UID_LEN },
2023 crate::srv::id::Uid,
2024 oxedyne_fe2o3_crypto::enc::EncryptionScheme,
2025 oxedyne_fe2o3_hash::hash::HashScheme,
2026 TestDb,
2027 >(&[], Some(&state), None, None, PATH_STATUS, "", &headers, "t").await);
2028
2029 let body = resp.body_as_string();
2030 assert!(body.contains("\"admin\":true"), "status did not report the passphrase admin: {}", body);
2031 assert!(body.contains("\"csrf\":\""), "status gave the passphrase admin no csrf token: {}", body);
2032 Ok(())
2033 }
2034
2035 /// An unauthenticated `GET /manage` is answered with the themed passphrase
2036 /// login form -- 200 with a password field, not a redirect.
2037 #[tokio::test]
2038 async fn test_unauthenticated_get_manage_is_a_login_form_10() -> Outcome<()> {
2039 let resp = res!(handle_get::<
2040 { crate::srv::id::UID_LEN },
2041 crate::srv::id::Uid,
2042 oxedyne_fe2o3_crypto::enc::EncryptionScheme,
2043 oxedyne_fe2o3_hash::hash::HashScheme,
2044 TestDb,
2045 >(&[], None, None, None, PATH_ROOT, "", &no_headers(), "t").await);
2046
2047 assert_eq!(status_of(&resp), Some(HttpStatus::OK), "an unauthenticated /manage must be 200, not a redirect");
2048 assert!(resp.header.fields.get_one(&HeaderName::Location).is_none(), "it must not be a redirect");
2049 let body = resp.body_as_string();
2050 assert!(body.contains("type=\"password\""), "the login form has a password field");
2051 assert!(body.contains("name=\"passphrase\""), "the field is named passphrase");
2052 assert!(body.contains(&fmt!("action=\"{}\"", PATH_LOGIN)), "the form posts to /manage/login");
2053 Ok(())
2054 }
2055
2056 /// A manage credential cannot open the operator dashboard: presented under the
2057 /// operator cookie name, the operator gate refuses it, so the two sessions
2058 /// stay strictly separate and the operator path is untouched.
2059 #[test]
2060 fn test_a_manage_session_is_not_an_operator_session_11() -> Outcome<()> {
2061 let state = res!(mkstate("jason", b"correct horse"));
2062 let value = res!(session::encode(&state, "jason"));
2063
2064 // The two cookies are distinct names, so neither is ever sent where the
2065 // other is read.
2066 assert_ne!(session::MANAGE_COOKIE_NAME,
2067 crate::srv::admin::session::SESSION_COOKIE_NAME,
2068 "the manage and operator cookies must not share a name");
2069
2070 // Even smuggled under the operator cookie name, a manage blob does not
2071 // decode as an operator principal: the operator gate returns None.
2072 let headers = cookie_headers(crate::srv::admin::session::SESSION_COOKIE_NAME, &value);
2073 assert!(crate::srv::admin::handler::extract_principal(&state, &headers).is_none(),
2074 "a manage credential was accepted by the operator dashboard");
2075
2076 // And the manage cookie is scoped to the site, never to /admin.
2077 let cookie = build_manage_cookie(value, false);
2078 let attrs = match cookie.attrs {
2079 Some(a) => a,
2080 None => return Err(err!("no attributes"; Invalid)),
2081 };
2082 assert!(attrs.contains(&SetCookieAttributes::Path("/".to_string())));
2083 assert!(!attrs.contains(&SetCookieAttributes::Path("/admin".to_string())));
2084 Ok(())
2085 }
2086
2087 /// A console page may not be served from a store unasked.
2088 ///
2089 /// It shows the site as it stands and it stands behind a session, so a store keeping one would
2090 /// show an admin's page to whoever asked next on that machine.
2091 #[test]
2092 fn test_a_console_page_is_never_held_12() -> Outcome<()> {
2093 let theme = Theme {
2094 site_name: fmt!("Elearnity"),
2095 css: vec![fmt!("/css/a.css")],
2096 home: fmt!("/"),
2097 };
2098 let admin = SiteAdmin { username: "a".repeat(64) };
2099 cache::assert_not_held(&page(&theme, &admin, "Posts", "<p>a body</p>"), "a console page");
2100 cache::assert_not_held(&not_yet_admin(&theme, &admin.username, true, "csrf"),
2101 "the claim page");
2102 cache::assert_not_held(&not_yet_admin(&theme, &admin.username, false, "csrf"),
2103 "the not-an-admin page");
2104 Ok(())
2105 }
2106}