api.rs

  1use crate::{
  2    auth,
  3    db::{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 serde::{Deserialize, Serialize};
 18use std::sync::Arc;
 19use tower::ServiceBuilder;
 20use tracing::instrument;
 21use util::{async_maybe, http::AsyncBody, ResultExt};
 22
 23pub fn routes(rpc_server: Arc<rpc::Server>, state: Arc<AppState>) -> Router<Body> {
 24    let called_from_website = 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        .layer(
 30            ServiceBuilder::new()
 31                .layer(Extension(state.clone()))
 32                .layer(Extension(rpc_server))
 33                .layer(middleware::from_fn(validate_api_token)),
 34        );
 35
 36    let called_from_client = Router::new().route("/crash", post(trace_crash)).layer(
 37        ServiceBuilder::new()
 38            .layer(Extension(state))
 39            .layer(middleware::from_fn(validate_client_secret)),
 40    );
 41
 42    called_from_website.merge(called_from_client)
 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
 76pub async fn validate_client_secret<B>(req: Request<B>, next: Next<B>) -> impl IntoResponse {
 77    let token = req
 78        .headers()
 79        .get(http::header::AUTHORIZATION)
 80        .and_then(|header| header.to_str().ok())
 81        .ok_or_else(|| {
 82            Error::Http(
 83                StatusCode::BAD_REQUEST,
 84                "missing authorization header".to_string(),
 85            )
 86        })?
 87        .strip_prefix("token ")
 88        .ok_or_else(|| {
 89            Error::Http(
 90                StatusCode::BAD_REQUEST,
 91                "invalid authorization header".to_string(),
 92            )
 93        })?;
 94
 95    let state = req.extensions().get::<Arc<AppState>>().unwrap();
 96
 97    if token != state.config.client_token {
 98        Err(Error::Http(
 99            StatusCode::UNAUTHORIZED,
100            "invalid client secret".to_string(),
101        ))?
102    }
103
104    Ok::<_, Error>(next.run(req).await)
105}
106
107#[derive(Debug, Deserialize)]
108struct AuthenticatedUserParams {
109    github_user_id: Option<i32>,
110    github_login: String,
111    github_email: Option<String>,
112}
113
114#[derive(Debug, Serialize)]
115struct AuthenticatedUserResponse {
116    user: User,
117    metrics_id: String,
118}
119
120async fn get_authenticated_user(
121    Query(params): Query<AuthenticatedUserParams>,
122    Extension(app): Extension<Arc<AppState>>,
123) -> Result<Json<AuthenticatedUserResponse>> {
124    let user = app
125        .db
126        .get_or_create_user_by_github_account(
127            &params.github_login,
128            params.github_user_id,
129            params.github_email.as_deref(),
130        )
131        .await?
132        .ok_or_else(|| Error::Http(StatusCode::NOT_FOUND, "user not found".into()))?;
133    let metrics_id = app.db.get_user_metrics_id(user.id).await?;
134    return Ok(Json(AuthenticatedUserResponse { user, metrics_id }));
135}
136
137#[derive(Deserialize, Debug)]
138struct CreateUserParams {
139    github_user_id: i32,
140    github_login: String,
141    email_address: String,
142    email_confirmation_code: Option<String>,
143    #[serde(default)]
144    admin: bool,
145    #[serde(default)]
146    invite_count: i32,
147}
148
149#[derive(Serialize, Debug)]
150struct CreateUserResponse {
151    user: User,
152    signup_device_id: Option<String>,
153    metrics_id: String,
154}
155
156#[derive(Debug, Deserialize)]
157struct Panic {
158    version: String,
159    release_channel: String,
160    backtrace_hash: String,
161    text: String,
162}
163
164#[instrument(skip(panic))]
165async fn trace_panic(panic: Json<Panic>) -> Result<()> {
166    tracing::error!(version = %panic.version, release_channel = %panic.release_channel, backtrace_hash = %panic.backtrace_hash, text = %panic.text, "panic report");
167    Ok(())
168}
169
170/// IPSHeader is the first line of an .ips file (in JSON format)
171/// https://developer.apple.com/documentation/xcode/interpreting-the-json-format-of-a-crash-report
172#[derive(Debug, Serialize, Deserialize)]
173struct IPSHeader {
174    timestamp: Option<String>,
175    name: Option<String>,
176    app_name: Option<String>,
177    app_version: Option<String>,
178    slice_uuid: Option<String>,
179    build_version: Option<String>,
180    platform: Option<i32>,
181    #[serde(rename = "bundleID")]
182    bundle_id: Option<String>,
183    share_with_app_devs: Option<i32>,
184    is_first_party: Option<i32>,
185    bug_type: Option<String>,
186    os_version: Option<String>,
187    roots_installed: Option<i32>,
188    incident_id: Option<String>,
189}
190
191#[instrument(skip(content, app))]
192async fn trace_crash(content: String, Extension(app): Extension<Arc<AppState>>) -> Result<()> {
193    let Some(header) = content.split("\n").next() else {
194        return Err(Error::Http(
195            StatusCode::BAD_REQUEST,
196            "invalid .ips file".to_string(),
197        ));
198    };
199    let header: IPSHeader = serde_json::from_slice(&header.as_bytes())?;
200    let text = content.as_str();
201
202    tracing::error!(app_version = %header.app_version.clone().unwrap_or_default(),
203        build_version = %header.build_version.unwrap_or_default(),
204        os_version = %header.os_version.unwrap_or_default(),
205        bundle_id = %header.bundle_id.clone().unwrap_or_default(),
206        text = %text,
207    "crash report");
208
209    async_maybe!({
210        let api_key = app.config.slack_api_key.clone()?;
211        let channel = app.config.slack_panic_channel.clone()?;
212
213        let mut body = form_data_builder::FormData::new(Vec::new());
214        body.write_field("content", text).log_err()?;
215        body.write_field("channels", channel.as_str()).log_err()?;
216        body.write_field(
217            "filename",
218            format!("zed-crash-{}.ips", header.incident_id.unwrap_or_default()).as_str(),
219        )
220        .log_err()?;
221        body.write_field(
222            "initial_comment",
223            format!(
224                "New crash in {} ({})",
225                header.bundle_id.unwrap_or_default(),
226                header.app_version.unwrap_or_default()
227            )
228            .as_str(),
229        )
230        .log_err()?;
231        let content_type = body.content_type_header();
232        let body = AsyncBody::from(body.finish().log_err()?);
233
234        let request = Request::post("https://slack.com/api/files.upload")
235            .header("Content-Type", content_type)
236            .header("Authorization", format!("Bearer {}", api_key))
237            .body(body)
238            .log_err()?;
239
240        let response = util::http::client().send(request).await.log_err()?;
241        if !response.status().is_success() {
242            tracing::error!(response = ?response, "failed to send crash report to slack");
243        }
244
245        Some(())
246    })
247    .await;
248    Ok(())
249}
250
251async fn get_rpc_server_snapshot(
252    Extension(rpc_server): Extension<Arc<rpc::Server>>,
253) -> Result<ErasedJson> {
254    Ok(ErasedJson::pretty(rpc_server.snapshot().await))
255}
256
257#[derive(Deserialize)]
258struct CreateAccessTokenQueryParams {
259    public_key: String,
260    impersonate: Option<String>,
261}
262
263#[derive(Serialize)]
264struct CreateAccessTokenResponse {
265    user_id: UserId,
266    encrypted_access_token: String,
267}
268
269async fn create_access_token(
270    Path(user_id): Path<UserId>,
271    Query(params): Query<CreateAccessTokenQueryParams>,
272    Extension(app): Extension<Arc<AppState>>,
273) -> Result<Json<CreateAccessTokenResponse>> {
274    let user = app
275        .db
276        .get_user_by_id(user_id)
277        .await?
278        .ok_or_else(|| anyhow!("user not found"))?;
279
280    let mut user_id = user.id;
281    if let Some(impersonate) = params.impersonate {
282        if user.admin {
283            if let Some(impersonated_user) = app.db.get_user_by_github_login(&impersonate).await? {
284                user_id = impersonated_user.id;
285            } else {
286                return Err(Error::Http(
287                    StatusCode::UNPROCESSABLE_ENTITY,
288                    format!("user {impersonate} does not exist"),
289                ));
290            }
291        } else {
292            return Err(Error::Http(
293                StatusCode::UNAUTHORIZED,
294                "you do not have permission to impersonate other users".to_string(),
295            ));
296        }
297    }
298
299    let access_token = auth::create_access_token(app.db.as_ref(), user_id).await?;
300    let encrypted_access_token =
301        auth::encrypt_access_token(&access_token, params.public_key.clone())?;
302
303    Ok(Json(CreateAccessTokenResponse {
304        user_id,
305        encrypted_access_token,
306    }))
307}