1use crate::{
2 auth,
3 db::{ContributorSelector, User, UserId},
4 rpc, AppState, Error, Result,
5};
6use anyhow::anyhow;
7use axum::{
8 body::Body,
9 extract::{Path, Query},
10 http::{self, Request, StatusCode},
11 middleware::{self, Next},
12 response::IntoResponse,
13 routing::{get, post},
14 Extension, Json, Router,
15};
16use axum_extra::response::ErasedJson;
17use chrono::SecondsFormat;
18use serde::{Deserialize, Serialize};
19use std::sync::Arc;
20use tower::ServiceBuilder;
21use tracing::instrument;
22
23pub fn routes(rpc_server: Arc<rpc::Server>, state: Arc<AppState>) -> Router<Body> {
24 Router::new()
25 .route("/user", get(get_authenticated_user))
26 .route("/users/:id/access_tokens", post(create_access_token))
27 .route("/panic", post(trace_panic))
28 .route("/rpc_server_snapshot", get(get_rpc_server_snapshot))
29 .route("/contributors", get(get_contributors).post(add_contributor))
30 .route("/contributor", get(check_is_contributor))
31 .layer(
32 ServiceBuilder::new()
33 .layer(Extension(state))
34 .layer(Extension(rpc_server))
35 .layer(middleware::from_fn(validate_api_token)),
36 )
37}
38
39pub async fn validate_api_token<B>(req: Request<B>, next: Next<B>) -> impl IntoResponse {
40 let token = req
41 .headers()
42 .get(http::header::AUTHORIZATION)
43 .and_then(|header| header.to_str().ok())
44 .ok_or_else(|| {
45 Error::Http(
46 StatusCode::BAD_REQUEST,
47 "missing authorization header".to_string(),
48 )
49 })?
50 .strip_prefix("token ")
51 .ok_or_else(|| {
52 Error::Http(
53 StatusCode::BAD_REQUEST,
54 "invalid authorization header".to_string(),
55 )
56 })?;
57
58 let state = req.extensions().get::<Arc<AppState>>().unwrap();
59
60 if token != state.config.api_token {
61 Err(Error::Http(
62 StatusCode::UNAUTHORIZED,
63 "invalid authorization token".to_string(),
64 ))?
65 }
66
67 Ok::<_, Error>(next.run(req).await)
68}
69
70#[derive(Debug, Deserialize)]
71struct AuthenticatedUserParams {
72 github_user_id: Option<i32>,
73 github_login: String,
74 github_email: Option<String>,
75}
76
77#[derive(Debug, Serialize)]
78struct AuthenticatedUserResponse {
79 user: User,
80 metrics_id: String,
81}
82
83async fn get_authenticated_user(
84 Query(params): Query<AuthenticatedUserParams>,
85 Extension(app): Extension<Arc<AppState>>,
86) -> Result<Json<AuthenticatedUserResponse>> {
87 let user = app
88 .db
89 .get_or_create_user_by_github_account(
90 ¶ms.github_login,
91 params.github_user_id,
92 params.github_email.as_deref(),
93 )
94 .await?;
95 let metrics_id = app.db.get_user_metrics_id(user.id).await?;
96 return Ok(Json(AuthenticatedUserResponse { user, metrics_id }));
97}
98
99#[derive(Deserialize, Debug)]
100struct CreateUserParams {
101 github_user_id: i32,
102 github_login: String,
103 email_address: String,
104 email_confirmation_code: Option<String>,
105 #[serde(default)]
106 admin: bool,
107 #[serde(default)]
108 invite_count: i32,
109}
110
111#[derive(Serialize, Debug)]
112struct CreateUserResponse {
113 user: User,
114 signup_device_id: Option<String>,
115 metrics_id: String,
116}
117
118#[derive(Debug, Deserialize)]
119struct Panic {
120 version: String,
121 release_channel: String,
122 backtrace_hash: String,
123 text: String,
124}
125
126#[instrument(skip(panic))]
127async fn trace_panic(panic: Json<Panic>) -> Result<()> {
128 tracing::error!(version = %panic.version, release_channel = %panic.release_channel, backtrace_hash = %panic.backtrace_hash, text = %panic.text, "panic report");
129 Ok(())
130}
131
132async fn get_rpc_server_snapshot(
133 Extension(rpc_server): Extension<Arc<rpc::Server>>,
134) -> Result<ErasedJson> {
135 Ok(ErasedJson::pretty(rpc_server.snapshot().await))
136}
137
138async fn get_contributors(Extension(app): Extension<Arc<AppState>>) -> Result<Json<Vec<String>>> {
139 Ok(Json(app.db.get_contributors().await?))
140}
141
142#[derive(Debug, Deserialize)]
143struct CheckIsContributorParams {
144 github_user_id: Option<i32>,
145 github_login: Option<String>,
146}
147
148impl CheckIsContributorParams {
149 fn as_contributor_selector(self) -> Result<ContributorSelector> {
150 if let Some(github_user_id) = self.github_user_id {
151 return Ok(ContributorSelector::GitHubUserId { github_user_id });
152 }
153
154 if let Some(github_login) = self.github_login {
155 return Ok(ContributorSelector::GitHubLogin { github_login });
156 }
157
158 Err(anyhow!(
159 "must be one of `github_user_id` or `github_login`."
160 ))?
161 }
162}
163
164#[derive(Debug, Serialize)]
165struct CheckIsContributorResponse {
166 signed_at: Option<String>,
167}
168
169async fn check_is_contributor(
170 Extension(app): Extension<Arc<AppState>>,
171 Query(params): Query<CheckIsContributorParams>,
172) -> Result<Json<CheckIsContributorResponse>> {
173 let params = params.as_contributor_selector()?;
174 Ok(Json(CheckIsContributorResponse {
175 signed_at: app
176 .db
177 .get_contributor_sign_timestamp(¶ms)
178 .await?
179 .map(|ts| ts.and_utc().to_rfc3339_opts(SecondsFormat::Millis, true)),
180 }))
181}
182
183async fn add_contributor(
184 Json(params): Json<AuthenticatedUserParams>,
185 Extension(app): Extension<Arc<AppState>>,
186) -> Result<()> {
187 Ok(app
188 .db
189 .add_contributor(
190 ¶ms.github_login,
191 params.github_user_id,
192 params.github_email.as_deref(),
193 )
194 .await?)
195}
196
197#[derive(Deserialize)]
198struct CreateAccessTokenQueryParams {
199 public_key: String,
200 impersonate: Option<String>,
201}
202
203#[derive(Serialize)]
204struct CreateAccessTokenResponse {
205 user_id: UserId,
206 encrypted_access_token: String,
207}
208
209async fn create_access_token(
210 Path(user_id): Path<UserId>,
211 Query(params): Query<CreateAccessTokenQueryParams>,
212 Extension(app): Extension<Arc<AppState>>,
213) -> Result<Json<CreateAccessTokenResponse>> {
214 let user = app
215 .db
216 .get_user_by_id(user_id)
217 .await?
218 .ok_or_else(|| anyhow!("user not found"))?;
219
220 let mut impersonated_user_id = None;
221 if let Some(impersonate) = params.impersonate {
222 if user.admin {
223 if let Some(impersonated_user) = app.db.get_user_by_github_login(&impersonate).await? {
224 impersonated_user_id = Some(impersonated_user.id);
225 } else {
226 return Err(Error::Http(
227 StatusCode::UNPROCESSABLE_ENTITY,
228 format!("user {impersonate} does not exist"),
229 ));
230 }
231 } else {
232 return Err(Error::Http(
233 StatusCode::UNAUTHORIZED,
234 "you do not have permission to impersonate other users".to_string(),
235 ));
236 }
237 }
238
239 let access_token =
240 auth::create_access_token(app.db.as_ref(), user_id, impersonated_user_id).await?;
241 let encrypted_access_token =
242 auth::encrypt_access_token(&access_token, params.public_key.clone())?;
243
244 Ok(Json(CreateAccessTokenResponse {
245 user_id: impersonated_user_id.unwrap_or(user_id),
246 encrypted_access_token,
247 }))
248}