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::{
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 tokens(cx: &Cx) -> SqliteTokenRepo {
72 SqliteTokenRepo::new(pool(cx).clone())
73}
74
75/// Repository contents, read from the same data directory as [`storage`].
76pub fn queries(cx: &Cx) -> DiskGitQuery {
77 DiskGitQuery::new(app_context::<AppConfig>(cx).data_dir.clone())
78}
79
80/// The git smart-HTTP protocol, rooted at the same data directory as [`storage`].
81pub fn protocol(cx: &Cx) -> GitHttpBackend {
82 GitHttpBackend::new(app_context::<AppConfig>(cx).data_dir.clone())
83}
84
85/// Who is making this request.
86///
87/// Resolves to [`Actor::Anonymous`] when there is no session, the session is unknown,
88/// or it has expired.
89pub async fn current_actor(cx: &Cx) -> Result<Actor> {
90 let presented = session::token_hash(cx)
91 .await?
92 .map(|hash| SessionTokenHash::from_trusted(hex(&hash)));
93
94 let sessions = SqliteSessionRepo::new(pool(cx).clone());
95
96 resolve_actor(presented.as_ref(), SystemTime::now(), &sessions)
97 .await
98 .map_err(server_error)
99}
100
101/// Who the current actor actually is, resolved for display.
102///
103/// `None` when anonymous, or when the session outlived the user it names.
104pub async fn identity(cx: &Cx) -> Result<Option<Identity>> {
105 let actor = current_actor(cx).await?;
106 let pool = pool(cx).clone();
107
108 describe_identity(
109 &actor,
110 &SqliteUserRepo::new(pool.clone()),
111 &SqliteOrgRepo::new(pool),
112 )
113 .await
114 .map_err(server_error)
115}
116
117/// Whether this installation has an owner yet.
118pub async fn claimed(cx: &Cx) -> Result<bool> {
119 let users = SqliteUserRepo::new(pool(cx).clone());
120
121 is_claimed(&users).await.map_err(server_error)
122}
123
124/// The setup token, when the instance is still unclaimed.
125pub fn setup_token(cx: &Cx) -> Option<&crate::domain::SetupToken> {
126 topcoat::context::try_app_context::<SetupState>(cx).map(|state| &state.0)
127}
128
129/// The `Location` header for a post-redirect-get reply.
130///
131/// Pair it with `StatusCode::SEE_OTHER` inside a `view!` to redirect from a page that
132/// otherwise renders a view:
133///
134/// ```ignore
135/// view! { (StatusCode::SEE_OTHER) (location("/somewhere")?) }
136/// ```
137///
138/// The awkward-looking route to a 303: `redirect()` is a **307**, which preserves the
139/// method, so a browser re-POSTs the form to its target instead of fetching it.
140/// `see_other()` is the right status but is a response type, and `#[page]` handlers
141/// must return a view so the layout can wrap the failure re-render. Setting the status
142/// and header inside `view!` is the documented way to get both from one handler.
143pub fn location(uri: &str) -> Result<(HeaderName, HeaderValue)> {
144 Ok((LOCATION, HeaderValue::try_from(uri).map_err(server_error)?))
145}
146
147/// The origin this instance is being reached on, e.g. `https://steid.example`.
148///
149/// Derived from the request rather than configured, so an instance is deployable
150/// anywhere without being told its own address — behind a proxy, on a platform
151/// subdomain, or on localhost, all with no configuration.
152///
153/// The scheme comes from `X-Forwarded-Proto` when a proxy sets it, since TLS is
154/// terminated in front of us and the request that arrives here is plain HTTP. Falling
155/// back to `http` is right for local development and wrong nowhere that matters: a
156/// deployment without a terminating proxy has no TLS to advertise anyway.
157pub fn public_origin(cx: &Cx) -> String {
158 let headers = headers(cx);
159 let value = |name: &str| headers.get(name).and_then(|value| value.to_str().ok());
160
161 let scheme = value("x-forwarded-proto").unwrap_or("http");
162 let host = headers
163 .get(HOST)
164 .and_then(|value| value.to_str().ok())
165 .unwrap_or("localhost");
166
167 format!("{scheme}://{host}")
168}
169
170/// Renders a `TokenHash` as lowercase hex for storage.
171pub fn hex(hash: &session::TokenHash) -> String {
172 hash.iter().map(|byte| format!("{byte:02x}")).collect()
173}