steid

@jamesgill /

14223e7feat: claim, sign in, and sign out through the browser1mo
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},
acde6b5feat: show the clone URL on a repository page8d
13 router::{
14 HeaderName, HeaderValue,
15 error::internal_server_error,
16 header::{HOST, LOCATION},
17 headers,
18 },
14223e7feat: claim, sign in, and sign out through the browser1mo
19 session,
20};
21
22use crate::{
00d9c06refactor: one place decides that git ran out of time17h
23 application::{AppConfig, Error, Identity, describe_identity, is_claimed, resolve_actor},
14223e7feat: claim, sign in, and sign out through the browser1mo
24 domain::{Actor, SessionTokenHash},
285f5fdfeat: create and view repositories through the browser24d
25 infrastructure::{
b7a57dbfeat: a repository can be taken away as a file19h
26 git::{DiskGitArchive, DiskGitStorage, GitHttpBackend},
dce0bf3feat: browse a repository's files and history8d
27 git_query::DiskGitQuery,
285f5fdfeat: create and view repositories through the browser24d
28 repository::{
a814db5feat: push and clone private repositories with a token8d
29 SqliteMembershipRepo, SqliteOrgRepo, SqliteRepoRepo, SqliteSessionRepo,
30 SqliteTokenRepo, SqliteUserRepo,
285f5fdfeat: create and view repositories through the browser24d
31 },
a69e380feat: public profile page at /{handle}1mo
32 },
14223e7feat: claim, sign in, and sign out through the browser1mo
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.
a69e380feat: public profile page at /{handle}1mo
46pub fn server_error<E>(error: E) -> topcoat::Error
14223e7feat: claim, sign in, and sign out through the browser1mo
47where
48 E: std::error::Error + Send + Sync + 'static,
49{
50 eprintln!("steid: {error}");
51 internal_server_error(error).into()
52}
53
00d9c06refactor: one place decides that git ran out of time17h
54/// Whether a use case failed because git was too slow rather than because git broke.
55///
56/// A timeout is the one read failure that is not a fault: the repository is fine and the
57/// request asked more of it than one page's budget allows. A page that can say so
58/// renders a state of its own — in its own words, offering whatever costs less — rather
59/// than a 500 that claims the instance is broken.
60///
61/// Four pages wanted this within one wave (`refs`, `commit`, `blame`, and `search`
62/// before it moved), which is what it took to earn a home here. `search_repo` is the
63/// exception and stays as it is: it answers `Searched::TimedOut` in the application
64/// layer, because there the timeout is one of several results the same form re-renders
65/// rather than an error to be caught.
66pub fn timed_out(error: &Error) -> bool {
67 matches!(error, Error::GitQuery(query) if query.is_timeout())
68}
69
a69e380feat: public profile page at /{handle}1mo
70pub fn orgs(cx: &Cx) -> SqliteOrgRepo {
71 SqliteOrgRepo::new(pool(cx).clone())
72}
73
74pub fn memberships(cx: &Cx) -> SqliteMembershipRepo {
75 SqliteMembershipRepo::new(pool(cx).clone())
76}
77
285f5fdfeat: create and view repositories through the browser24d
78pub fn repos(cx: &Cx) -> SqliteRepoRepo {
79 SqliteRepoRepo::new(pool(cx).clone())
80}
81
82/// Bare repositories on disk, rooted at the configured data directory.
83pub fn storage(cx: &Cx) -> DiskGitStorage {
84 DiskGitStorage::new(app_context::<AppConfig>(cx).data_dir.clone())
85}
86
8ed4e5afeat: the root is the owner's profile1d
87pub fn users(cx: &Cx) -> SqliteUserRepo {
88 SqliteUserRepo::new(pool(cx).clone())
89}
90
a814db5feat: push and clone private repositories with a token8d
91pub fn tokens(cx: &Cx) -> SqliteTokenRepo {
92 SqliteTokenRepo::new(pool(cx).clone())
93}
94
dce0bf3feat: browse a repository's files and history8d
95/// Repository contents, read from the same data directory as [`storage`].
96pub fn queries(cx: &Cx) -> DiskGitQuery {
97 DiskGitQuery::new(app_context::<AppConfig>(cx).data_dir.clone())
98}
99
b7a57dbfeat: a repository can be taken away as a file19h
100/// `git archive`, rooted at the same data directory as [`storage`].
101pub fn archives(cx: &Cx) -> DiskGitArchive {
102 DiskGitArchive::new(app_context::<AppConfig>(cx).data_dir.clone())
103}
104
e0856ebfeat: clone a public repository over HTTP8d
105/// The git smart-HTTP protocol, rooted at the same data directory as [`storage`].
106pub fn protocol(cx: &Cx) -> GitHttpBackend {
107 GitHttpBackend::new(app_context::<AppConfig>(cx).data_dir.clone())
108}
109
14223e7feat: claim, sign in, and sign out through the browser1mo
110/// Who is making this request.
111///
112/// Resolves to [`Actor::Anonymous`] when there is no session, the session is unknown,
113/// or it has expired.
114pub async fn current_actor(cx: &Cx) -> Result<Actor> {
115 let presented = session::token_hash(cx)
116 .await?
117 .map(|hash| SessionTokenHash::from_trusted(hex(&hash)));
118
119 let sessions = SqliteSessionRepo::new(pool(cx).clone());
120
121 resolve_actor(presented.as_ref(), SystemTime::now(), &sessions)
122 .await
123 .map_err(server_error)
124}
125
40ab5c7feat: /api/me and a shared identity read model1mo
126/// Who the current actor actually is, resolved for display.
127///
128/// `None` when anonymous, or when the session outlived the user it names.
129pub async fn identity(cx: &Cx) -> Result<Option<Identity>> {
130 let actor = current_actor(cx).await?;
131 let pool = pool(cx).clone();
132
133 describe_identity(
134 &actor,
135 &SqliteUserRepo::new(pool.clone()),
136 &SqliteOrgRepo::new(pool),
137 )
138 .await
139 .map_err(server_error)
140}
141
14223e7feat: claim, sign in, and sign out through the browser1mo
142/// Whether this installation has an owner yet.
143pub async fn claimed(cx: &Cx) -> Result<bool> {
144 let users = SqliteUserRepo::new(pool(cx).clone());
145
146 is_claimed(&users).await.map_err(server_error)
147}
148
149/// The setup token, when the instance is still unclaimed.
150pub fn setup_token(cx: &Cx) -> Option<&crate::domain::SetupToken> {
151 topcoat::context::try_app_context::<SetupState>(cx).map(|state| &state.0)
152}
153
285f5fdfeat: create and view repositories through the browser24d
154/// The `Location` header for a post-redirect-get reply.
155///
156/// Pair it with `StatusCode::SEE_OTHER` inside a `view!` to redirect from a page that
157/// otherwise renders a view:
158///
159/// ```ignore
160/// view! { (StatusCode::SEE_OTHER) (location("/somewhere")?) }
161/// ```
162///
163/// The awkward-looking route to a 303: `redirect()` is a **307**, which preserves the
164/// method, so a browser re-POSTs the form to its target instead of fetching it.
165/// `see_other()` is the right status but is a response type, and `#[page]` handlers
166/// must return a view so the layout can wrap the failure re-render. Setting the status
167/// and header inside `view!` is the documented way to get both from one handler.
168pub fn location(uri: &str) -> Result<(HeaderName, HeaderValue)> {
169 Ok((LOCATION, HeaderValue::try_from(uri).map_err(server_error)?))
170}
171
acde6b5feat: show the clone URL on a repository page8d
172/// The origin this instance is being reached on, e.g. `https://steid.example`.
173///
174/// Derived from the request rather than configured, so an instance is deployable
175/// anywhere without being told its own address — behind a proxy, on a platform
176/// subdomain, or on localhost, all with no configuration.
177///
178/// The scheme comes from `X-Forwarded-Proto` when a proxy sets it, since TLS is
179/// terminated in front of us and the request that arrives here is plain HTTP. Falling
180/// back to `http` is right for local development and wrong nowhere that matters: a
181/// deployment without a terminating proxy has no TLS to advertise anyway.
182pub fn public_origin(cx: &Cx) -> String {
183 let headers = headers(cx);
184 let value = |name: &str| headers.get(name).and_then(|value| value.to_str().ok());
185
186 let scheme = value("x-forwarded-proto").unwrap_or("http");
187 let host = headers
188 .get(HOST)
189 .and_then(|value| value.to_str().ok())
190 .unwrap_or("localhost");
191
192 format!("{scheme}://{host}")
193}
194
14223e7feat: claim, sign in, and sign out through the browser1mo
195/// Renders a `TokenHash` as lowercase hex for storage.
196pub fn hex(hash: &session::TokenHash) -> String {
197 hash.iter().map(|byte| format!("{byte:02x}")).collect()
198}