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