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