api.rs

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