api.rs

  1use crate::{
  2    auth,
  3    db::{ProjectId, User, UserId},
  4    rpc::{self, ResultExt},
  5    AppState, Error, Result,
  6};
  7use anyhow::anyhow;
  8use axum::{
  9    body::Body,
 10    extract::{Path, Query},
 11    http::{self, Request, StatusCode},
 12    middleware::{self, Next},
 13    response::IntoResponse,
 14    routing::{get, post, put},
 15    Extension, Json, Router,
 16};
 17use axum_extra::response::ErasedJson;
 18use serde::{Deserialize, Serialize};
 19use serde_json::json;
 20use std::sync::Arc;
 21use time::OffsetDateTime;
 22use tower::ServiceBuilder;
 23use tracing::instrument;
 24
 25pub fn routes(rpc_server: &Arc<rpc::Server>, state: Arc<AppState>) -> Router<Body> {
 26    Router::new()
 27        .route("/users", get(get_users).post(create_user))
 28        .route(
 29            "/users/:id",
 30            put(update_user).delete(destroy_user).get(get_user),
 31        )
 32        .route("/users/:id/access_tokens", post(create_access_token))
 33        .route("/bulk_users", post(create_users))
 34        .route("/invite_codes/:code", get(get_user_for_invite_code))
 35        .route("/panic", post(trace_panic))
 36        .route("/rpc_server_snapshot", get(get_rpc_server_snapshot))
 37        .route(
 38            "/project_activity_summary",
 39            get(get_project_activity_summary),
 40        )
 41        .route("/project_metadata", get(get_project_metadata))
 42        .layer(
 43            ServiceBuilder::new()
 44                .layer(Extension(state))
 45                .layer(Extension(rpc_server.clone()))
 46                .layer(middleware::from_fn(validate_api_token)),
 47        )
 48}
 49
 50pub async fn validate_api_token<B>(req: Request<B>, next: Next<B>) -> impl IntoResponse {
 51    let token = req
 52        .headers()
 53        .get(http::header::AUTHORIZATION)
 54        .and_then(|header| header.to_str().ok())
 55        .ok_or_else(|| {
 56            Error::Http(
 57                StatusCode::BAD_REQUEST,
 58                "missing authorization header".to_string(),
 59            )
 60        })?
 61        .strip_prefix("token ")
 62        .ok_or_else(|| {
 63            Error::Http(
 64                StatusCode::BAD_REQUEST,
 65                "invalid authorization header".to_string(),
 66            )
 67        })?;
 68
 69    let state = req.extensions().get::<Arc<AppState>>().unwrap();
 70
 71    if token != state.api_token {
 72        Err(Error::Http(
 73            StatusCode::UNAUTHORIZED,
 74            "invalid authorization token".to_string(),
 75        ))?
 76    }
 77
 78    Ok::<_, Error>(next.run(req).await)
 79}
 80
 81#[derive(Debug, Deserialize)]
 82struct GetUsersQueryParams {
 83    query: Option<String>,
 84    page: Option<u32>,
 85    limit: Option<u32>,
 86}
 87
 88async fn get_users(
 89    Query(params): Query<GetUsersQueryParams>,
 90    Extension(app): Extension<Arc<AppState>>,
 91) -> Result<Json<Vec<User>>> {
 92    let limit = params.limit.unwrap_or(100);
 93    let users = if let Some(query) = params.query {
 94        app.db.fuzzy_search_users(&query, limit).await?
 95    } else {
 96        app.db
 97            .get_all_users(params.page.unwrap_or(0), limit)
 98            .await?
 99    };
100    Ok(Json(users))
101}
102
103#[derive(Deserialize, Debug)]
104struct CreateUserParams {
105    github_login: String,
106    invite_code: Option<String>,
107    email_address: Option<String>,
108    admin: bool,
109}
110
111async fn create_user(
112    Json(params): Json<CreateUserParams>,
113    Extension(app): Extension<Arc<AppState>>,
114    Extension(rpc_server): Extension<Arc<rpc::Server>>,
115) -> Result<Json<User>> {
116    let user_id = if let Some(invite_code) = params.invite_code {
117        let invitee_id = app
118            .db
119            .redeem_invite_code(
120                &invite_code,
121                &params.github_login,
122                params.email_address.as_deref(),
123            )
124            .await?;
125        rpc_server
126            .invite_code_redeemed(&invite_code, invitee_id)
127            .await
128            .trace_err();
129        invitee_id
130    } else {
131        app.db
132            .create_user(
133                &params.github_login,
134                params.email_address.as_deref(),
135                params.admin,
136            )
137            .await?
138    };
139
140    let user = app
141        .db
142        .get_user_by_id(user_id)
143        .await?
144        .ok_or_else(|| anyhow!("couldn't find the user we just created"))?;
145
146    Ok(Json(user))
147}
148
149#[derive(Deserialize)]
150struct UpdateUserParams {
151    admin: Option<bool>,
152    invite_count: Option<u32>,
153}
154
155async fn update_user(
156    Path(user_id): Path<i32>,
157    Json(params): Json<UpdateUserParams>,
158    Extension(app): Extension<Arc<AppState>>,
159    Extension(rpc_server): Extension<Arc<rpc::Server>>,
160) -> Result<()> {
161    let user_id = UserId(user_id);
162
163    if let Some(admin) = params.admin {
164        app.db.set_user_is_admin(user_id, admin).await?;
165    }
166
167    if let Some(invite_count) = params.invite_count {
168        app.db.set_invite_count(user_id, invite_count).await?;
169        rpc_server.invite_count_updated(user_id).await.trace_err();
170    }
171
172    Ok(())
173}
174
175async fn destroy_user(
176    Path(user_id): Path<i32>,
177    Extension(app): Extension<Arc<AppState>>,
178) -> Result<()> {
179    app.db.destroy_user(UserId(user_id)).await?;
180    Ok(())
181}
182
183async fn get_user(
184    Path(login): Path<String>,
185    Extension(app): Extension<Arc<AppState>>,
186) -> Result<Json<User>> {
187    let user = app
188        .db
189        .get_user_by_github_login(&login)
190        .await?
191        .ok_or_else(|| Error::Http(StatusCode::NOT_FOUND, "User not found".to_string()))?;
192    Ok(Json(user))
193}
194
195#[derive(Deserialize)]
196struct CreateUsersParams {
197    users: Vec<CreateUsersEntry>,
198}
199
200#[derive(Deserialize)]
201struct CreateUsersEntry {
202    github_login: String,
203    email_address: String,
204    invite_count: usize,
205}
206
207async fn create_users(
208    Json(params): Json<CreateUsersParams>,
209    Extension(app): Extension<Arc<AppState>>,
210) -> Result<Json<Vec<User>>> {
211    let user_ids = app
212        .db
213        .create_users(
214            params
215                .users
216                .into_iter()
217                .map(|params| {
218                    (
219                        params.github_login,
220                        params.email_address,
221                        params.invite_count,
222                    )
223                })
224                .collect(),
225        )
226        .await?;
227    let users = app.db.get_users_by_ids(user_ids).await?;
228    Ok(Json(users))
229}
230
231#[derive(Debug, Deserialize)]
232struct Panic {
233    version: String,
234    text: String,
235}
236
237#[instrument(skip(panic))]
238async fn trace_panic(panic: Json<Panic>) -> Result<()> {
239    tracing::error!(version = %panic.version, text = %panic.text, "panic report");
240    Ok(())
241}
242
243async fn get_rpc_server_snapshot(
244    Extension(rpc_server): Extension<Arc<rpc::Server>>,
245) -> Result<ErasedJson> {
246    Ok(ErasedJson::pretty(rpc_server.snapshot().await))
247}
248
249#[derive(Deserialize)]
250struct GetProjectActivityParams {
251    #[serde(with = "time::serde::iso8601")]
252    start: OffsetDateTime,
253    #[serde(with = "time::serde::iso8601")]
254    end: OffsetDateTime,
255}
256
257async fn get_project_activity_summary(
258    Query(params): Query<GetProjectActivityParams>,
259    Extension(app): Extension<Arc<AppState>>,
260) -> Result<ErasedJson> {
261    let summary = app
262        .db
263        .summarize_project_activity(params.start..params.end, 100)
264        .await?;
265    Ok(ErasedJson::pretty(summary))
266}
267
268#[derive(Deserialize)]
269struct GetProjectMetadataParams {
270    project_id: u64,
271}
272
273async fn get_project_metadata(
274    Query(params): Query<GetProjectMetadataParams>,
275    Extension(app): Extension<Arc<AppState>>,
276) -> Result<ErasedJson> {
277    let extensions = app
278        .db
279        .get_project_extensions(ProjectId::from_proto(params.project_id))
280        .await?;
281    Ok(ErasedJson::pretty(json!({ "extensions": extensions })))
282}
283
284#[derive(Deserialize)]
285struct CreateAccessTokenQueryParams {
286    public_key: String,
287    impersonate: Option<String>,
288}
289
290#[derive(Serialize)]
291struct CreateAccessTokenResponse {
292    user_id: UserId,
293    encrypted_access_token: String,
294}
295
296async fn create_access_token(
297    Path(login): Path<String>,
298    Query(params): Query<CreateAccessTokenQueryParams>,
299    Extension(app): Extension<Arc<AppState>>,
300) -> Result<Json<CreateAccessTokenResponse>> {
301    //     request.require_token().await?;
302
303    let user = app
304        .db
305        .get_user_by_github_login(&login)
306        .await?
307        .ok_or_else(|| anyhow!("user not found"))?;
308
309    let mut user_id = user.id;
310    if let Some(impersonate) = params.impersonate {
311        if user.admin {
312            if let Some(impersonated_user) = app.db.get_user_by_github_login(&impersonate).await? {
313                user_id = impersonated_user.id;
314            } else {
315                return Err(Error::Http(
316                    StatusCode::UNPROCESSABLE_ENTITY,
317                    format!("user {impersonate} does not exist"),
318                ));
319            }
320        } else {
321            return Err(Error::Http(
322                StatusCode::UNAUTHORIZED,
323                format!("you do not have permission to impersonate other users"),
324            ));
325        }
326    }
327
328    let access_token = auth::create_access_token(app.db.as_ref(), user_id).await?;
329    let encrypted_access_token =
330        auth::encrypt_access_token(&access_token, params.public_key.clone())?;
331
332    Ok(Json(CreateAccessTokenResponse {
333        user_id,
334        encrypted_access_token,
335    }))
336}
337
338async fn get_user_for_invite_code(
339    Path(code): Path<String>,
340    Extension(app): Extension<Arc<AppState>>,
341) -> Result<Json<User>> {
342    Ok(Json(app.db.get_user_for_invite_code(&code).await?))
343}