@jpgilldev / steid

5.8 KBRaw
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::{
14 HeaderName, HeaderValue,
15 error::internal_server_error,
16 header::{HOST, LOCATION},
17 headers,
18 },
19 session,
20};
21
22use crate::{
23 application::{AppConfig, Identity, describe_identity, is_claimed, resolve_actor},
24 domain::{Actor, SessionTokenHash},
25 infrastructure::{
26 git::{DiskGitStorage, GitHttpBackend},
27 git_query::DiskGitQuery,
28 repository::{
29 SqliteMembershipRepo, SqliteOrgRepo, SqliteRepoRepo, SqliteSessionRepo,
30 SqliteTokenRepo, SqliteUserRepo,
31 },
32 },
33};
34
35/// The one-time token that authorises claiming an unclaimed instance.
36///
37/// Present in app context only while the installation is unclaimed.
38pub struct SetupState(pub crate::domain::SetupToken);
39
40pub fn pool(cx: &Cx) -> &SqlitePool {
41 app_context::<SqlitePool>(cx)
42}
43
44/// Anything below the web layer failing is a 500 — the visitor can't act on it, and
45/// the detail belongs in the log rather than the page.
46pub fn server_error<E>(error: E) -> topcoat::Error
47where
48 E: std::error::Error + Send + Sync + 'static,
49{
50 eprintln!("steid: {error}");
51 internal_server_error(error).into()
52}
53
54pub fn orgs(cx: &Cx) -> SqliteOrgRepo {
55 SqliteOrgRepo::new(pool(cx).clone())
56}
57
58pub fn memberships(cx: &Cx) -> SqliteMembershipRepo {
59 SqliteMembershipRepo::new(pool(cx).clone())
60}
61
62pub fn repos(cx: &Cx) -> SqliteRepoRepo {
63 SqliteRepoRepo::new(pool(cx).clone())
64}
65
66/// Bare repositories on disk, rooted at the configured data directory.
67pub fn storage(cx: &Cx) -> DiskGitStorage {
68 DiskGitStorage::new(app_context::<AppConfig>(cx).data_dir.clone())
69}
70
71pub fn users(cx: &Cx) -> SqliteUserRepo {
72 SqliteUserRepo::new(pool(cx).clone())
73}
74
75pub fn tokens(cx: &Cx) -> SqliteTokenRepo {
76 SqliteTokenRepo::new(pool(cx).clone())
77}
78
79/// Repository contents, read from the same data directory as [`storage`].
80pub fn queries(cx: &Cx) -> DiskGitQuery {
81 DiskGitQuery::new(app_context::<AppConfig>(cx).data_dir.clone())
82}
83
84/// The git smart-HTTP protocol, rooted at the same data directory as [`storage`].
85pub fn protocol(cx: &Cx) -> GitHttpBackend {
86 GitHttpBackend::new(app_context::<AppConfig>(cx).data_dir.clone())
87}
88
89/// Who is making this request.
90///
91/// Resolves to [`Actor::Anonymous`] when there is no session, the session is unknown,
92/// or it has expired.
93pub async fn current_actor(cx: &Cx) -> Result<Actor> {
94 let presented = session::token_hash(cx)
95 .await?
96 .map(|hash| SessionTokenHash::from_trusted(hex(&hash)));
97
98 let sessions = SqliteSessionRepo::new(pool(cx).clone());
99
100 resolve_actor(presented.as_ref(), SystemTime::now(), &sessions)
101 .await
102 .map_err(server_error)
103}
104
105/// Who the current actor actually is, resolved for display.
106///
107/// `None` when anonymous, or when the session outlived the user it names.
108pub async fn identity(cx: &Cx) -> Result<Option<Identity>> {
109 let actor = current_actor(cx).await?;
110 let pool = pool(cx).clone();
111
112 describe_identity(
113 &actor,
114 &SqliteUserRepo::new(pool.clone()),
115 &SqliteOrgRepo::new(pool),
116 )
117 .await
118 .map_err(server_error)
119}
120
121/// Whether this installation has an owner yet.
122pub async fn claimed(cx: &Cx) -> Result<bool> {
123 let users = SqliteUserRepo::new(pool(cx).clone());
124
125 is_claimed(&users).await.map_err(server_error)
126}
127
128/// The setup token, when the instance is still unclaimed.
129pub fn setup_token(cx: &Cx) -> Option<&crate::domain::SetupToken> {
130 topcoat::context::try_app_context::<SetupState>(cx).map(|state| &state.0)
131}
132
133/// The `Location` header for a post-redirect-get reply.
134///
135/// Pair it with `StatusCode::SEE_OTHER` inside a `view!` to redirect from a page that
136/// otherwise renders a view:
137///
138/// ```ignore
139/// view! { (StatusCode::SEE_OTHER) (location("/somewhere")?) }
140/// ```
141///
142/// The awkward-looking route to a 303: `redirect()` is a **307**, which preserves the
143/// method, so a browser re-POSTs the form to its target instead of fetching it.
144/// `see_other()` is the right status but is a response type, and `#[page]` handlers
145/// must return a view so the layout can wrap the failure re-render. Setting the status
146/// and header inside `view!` is the documented way to get both from one handler.
147pub fn location(uri: &str) -> Result<(HeaderName, HeaderValue)> {
148 Ok((LOCATION, HeaderValue::try_from(uri).map_err(server_error)?))
149}
150
151/// The origin this instance is being reached on, e.g. `https://steid.example`.
152///
153/// Derived from the request rather than configured, so an instance is deployable
154/// anywhere without being told its own address — behind a proxy, on a platform
155/// subdomain, or on localhost, all with no configuration.
156///
157/// The scheme comes from `X-Forwarded-Proto` when a proxy sets it, since TLS is
158/// terminated in front of us and the request that arrives here is plain HTTP. Falling
159/// back to `http` is right for local development and wrong nowhere that matters: a
160/// deployment without a terminating proxy has no TLS to advertise anyway.
161pub fn public_origin(cx: &Cx) -> String {
162 let headers = headers(cx);
163 let value = |name: &str| headers.get(name).and_then(|value| value.to_str().ok());
164
165 let scheme = value("x-forwarded-proto").unwrap_or("http");
166 let host = headers
167 .get(HOST)
168 .and_then(|value| value.to_str().ok())
169 .unwrap_or("localhost");
170
171 format!("{scheme}://{host}")
172}
173
174/// Renders a `TokenHash` as lowercase hex for storage.
175pub fn hex(hash: &session::TokenHash) -> String {
176 hash.iter().map(|byte| format!("{byte:02x}")).collect()
177}