steid

@jamesgill /

1//! Request-scoped helpers.
2//!
3//! Functions taking `cx: &Cx`, not middleware or extractors — Topcoat's guidance, and
4//! the safer shape: a page that forgets to call `current_actor` gets no actor, whereas
5//! a route added without its middleware silently gets someone else's.
6
7use std::time::SystemTime;
8
9use sqlx::SqlitePool;
10use topcoat::{
11 Result,
12 context::{Cx, app_context},
13 router::{HeaderName, HeaderValue, error::internal_server_error, header::LOCATION},
14 session,
15};
16
17use crate::{
18 application::{AppConfig, Identity, describe_identity, is_claimed, resolve_actor},
19 domain::{Actor, SessionTokenHash},
20 infrastructure::{
21 git::{DiskGitStorage, GitHttpBackend},
22 repository::{
23 SqliteMembershipRepo, SqliteOrgRepo, SqliteRepoRepo, SqliteSessionRepo, SqliteUserRepo,
24 },
25 },
26};
27
28/// The one-time token that authorises claiming an unclaimed instance.
29///
30/// Present in app context only while the installation is unclaimed.
31pub struct SetupState(pub crate::domain::SetupToken);
32
33pub fn pool(cx: &Cx) -> &SqlitePool {
34 app_context::<SqlitePool>(cx)
35}
36
37/// Anything below the web layer failing is a 500 — the visitor can't act on it, and
38/// the detail belongs in the log rather than the page.
39pub fn server_error<E>(error: E) -> topcoat::Error
40where
41 E: std::error::Error + Send + Sync + 'static,
42{
43 eprintln!("steid: {error}");
44 internal_server_error(error).into()
45}
46
47pub fn orgs(cx: &Cx) -> SqliteOrgRepo {
48 SqliteOrgRepo::new(pool(cx).clone())
49}
50
51pub fn memberships(cx: &Cx) -> SqliteMembershipRepo {
52 SqliteMembershipRepo::new(pool(cx).clone())
53}
54
55pub fn repos(cx: &Cx) -> SqliteRepoRepo {
56 SqliteRepoRepo::new(pool(cx).clone())
57}
58
59/// Bare repositories on disk, rooted at the configured data directory.
60pub fn storage(cx: &Cx) -> DiskGitStorage {
61 DiskGitStorage::new(app_context::<AppConfig>(cx).data_dir.clone())
62}
63
64/// The git smart-HTTP protocol, rooted at the same data directory as [`storage`].
65pub fn protocol(cx: &Cx) -> GitHttpBackend {
66 GitHttpBackend::new(app_context::<AppConfig>(cx).data_dir.clone())
67}
68
69/// Who is making this request.
70///
71/// Resolves to [`Actor::Anonymous`] when there is no session, the session is unknown,
72/// or it has expired.
73pub async fn current_actor(cx: &Cx) -> Result<Actor> {
74 let presented = session::token_hash(cx)
75 .await?
76 .map(|hash| SessionTokenHash::from_trusted(hex(&hash)));
77
78 let sessions = SqliteSessionRepo::new(pool(cx).clone());
79
80 resolve_actor(presented.as_ref(), SystemTime::now(), &sessions)
81 .await
82 .map_err(server_error)
83}
84
85/// Who the current actor actually is, resolved for display.
86///
87/// `None` when anonymous, or when the session outlived the user it names.
88pub async fn identity(cx: &Cx) -> Result<Option<Identity>> {
89 let actor = current_actor(cx).await?;
90 let pool = pool(cx).clone();
91
92 describe_identity(
93 &actor,
94 &SqliteUserRepo::new(pool.clone()),
95 &SqliteOrgRepo::new(pool),
96 )
97 .await
98 .map_err(server_error)
99}
100
101/// Whether this installation has an owner yet.
102pub async fn claimed(cx: &Cx) -> Result<bool> {
103 let users = SqliteUserRepo::new(pool(cx).clone());
104
105 is_claimed(&users).await.map_err(server_error)
106}
107
108/// The setup token, when the instance is still unclaimed.
109pub fn setup_token(cx: &Cx) -> Option<&crate::domain::SetupToken> {
110 topcoat::context::try_app_context::<SetupState>(cx).map(|state| &state.0)
111}
112
113/// The `Location` header for a post-redirect-get reply.
114///
115/// Pair it with `StatusCode::SEE_OTHER` inside a `view!` to redirect from a page that
116/// otherwise renders a view:
117///
118/// ```ignore
119/// view! { (StatusCode::SEE_OTHER) (location("/somewhere")?) }
120/// ```
121///
122/// The awkward-looking route to a 303: `redirect()` is a **307**, which preserves the
123/// method, so a browser re-POSTs the form to its target instead of fetching it.
124/// `see_other()` is the right status but is a response type, and `#[page]` handlers
125/// must return a view so the layout can wrap the failure re-render. Setting the status
126/// and header inside `view!` is the documented way to get both from one handler.
127pub fn location(uri: &str) -> Result<(HeaderName, HeaderValue)> {
128 Ok((LOCATION, HeaderValue::try_from(uri).map_err(server_error)?))
129}
130
131/// Renders a `TokenHash` as lowercase hex for storage.
132pub fn hex(hash: &session::TokenHash) -> String {
133 hash.iter().map(|byte| format!("{byte:02x}")).collect()
134}