diff --git a/Cargo.lock b/Cargo.lock index d66f6e4..45b8151 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -305,6 +305,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-targets 0.52.6", ] diff --git a/Cargo.toml b/Cargo.toml index 918761b..9fe5f50 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,7 @@ sqlx = { version = "0.8.2", features = [ "uuid", ] } uuid = { version = "1.4.1", features = ["serde", "v4"] } -chrono = "0.4.39" +chrono = { version = "0.4.26", features = ["serde"] } dotenv = "0.15.0" argon2 = "0.5.3" jwt = "0.16.0" diff --git a/src/error.rs b/src/error.rs index 6a4bfcb..868b28b 100644 --- a/src/error.rs +++ b/src/error.rs @@ -11,6 +11,7 @@ pub enum Errors { Ise(anyhow::Error), Unimplemented, Unauthorized, + JWTExpired, } pub enum AppError { @@ -73,6 +74,12 @@ impl IntoResponse for AppError { Errors::Unimplemented => { (StatusCode::NOT_IMPLEMENTED, "Not implemented").into_response() } + + Errors::JWTExpired => ( + StatusCode::UNAUTHORIZED, + "JWT has expired. Please log in again.", + ) + .into_response(), }, } } diff --git a/src/extractors/users.rs b/src/extractors/users.rs index 968479a..cd96327 100644 --- a/src/extractors/users.rs +++ b/src/extractors/users.rs @@ -1,19 +1,17 @@ -use std::sync::Arc; - use crate::*; -use anyhow::bail; use axum::{ async_trait, extract::{FromRef, FromRequestParts}, http::{header, request::Parts}, }; -use chrono::{DateTime, Utc}; use jwt::VerifyWithKey; use sqlx::types::Uuid; use util::auth::JWTClaims; -pub struct UserId(Uuid); +/// Remember, this is FromRequestParts, so it has to be ABOVE the extractors +/// that eat the entire request +pub struct UserId(pub Uuid); #[async_trait] impl FromRequestParts for UserId @@ -39,6 +37,10 @@ where .verify_with_key(&state.jwt_key) .map_err(|_| AppError::Error(Errors::Unauthorized))?; + if claims.exp < chrono::Utc::now().timestamp() { + return Err(AppError::Error(Errors::JWTExpired)); + } + Ok(UserId(claims.sub)) } } diff --git a/src/main.rs b/src/main.rs index 400d047..609a5e8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -72,6 +72,7 @@ async fn main() -> anyhow::Result<()> { .routes(routes!(index)) .with_state(state.clone()) .nest("/api/v1/auth", v1::auth::router(state.clone())) + .nest("/api/v1/exercises", v1::exercises::router(state.clone())) .split_for_parts(); let router = router.merge(SwaggerUi::new("/docs").url("/docs/openapi.json", api)); diff --git a/src/structs/exercise_types.rs b/src/structs/exercise_types.rs new file mode 100644 index 0000000..837e632 --- /dev/null +++ b/src/structs/exercise_types.rs @@ -0,0 +1,55 @@ +use anyhow::anyhow; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Clone, Debug, PartialEq, PartialOrd, sqlx::Type, Deserialize, Serialize, ToSchema)] +#[sqlx(type_name = "exercise_type", rename_all = "snake_case")] +pub enum ExerciseType { + Dumbbell, + Barbell, + Bodyweight, + Machine, + Kettlebell, + ResistanceBand, + Cable, + MedicineBall, + Plyometric, + PlateLoadedMachine, +} + +impl std::fmt::Display for ExerciseType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ExerciseType::Dumbbell => write!(f, "dumbbell"), + ExerciseType::Barbell => write!(f, "barbell"), + ExerciseType::Bodyweight => write!(f, "bodyweight"), + ExerciseType::Machine => write!(f, "machine"), + ExerciseType::Kettlebell => write!(f, "kettlebell"), + ExerciseType::ResistanceBand => write!(f, "resistance_band"), + ExerciseType::Cable => write!(f, "cable"), + ExerciseType::MedicineBall => write!(f, "medicine_ball"), + ExerciseType::Plyometric => write!(f, "plyometric"), + ExerciseType::PlateLoadedMachine => write!(f, "plate_loaded_machine"), + } + } +} + +impl std::str::FromStr for ExerciseType { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + match s { + "dumbbell" => Ok(ExerciseType::Dumbbell), + "barbell" => Ok(ExerciseType::Barbell), + "bodyweight" => Ok(ExerciseType::Bodyweight), + "machine" => Ok(ExerciseType::Machine), + "kettlebell" => Ok(ExerciseType::Kettlebell), + "resistance_band" => Ok(ExerciseType::ResistanceBand), + "cable" => Ok(ExerciseType::Cable), + "medicine_ball" => Ok(ExerciseType::MedicineBall), + "plyometric" => Ok(ExerciseType::Plyometric), + "plate_loaded_machine" => Ok(ExerciseType::PlateLoadedMachine), + _ => Err(anyhow!("Invalid exercise type")), + } + } +} diff --git a/src/structs/mod.rs b/src/structs/mod.rs index 913bd46..34ab820 100644 --- a/src/structs/mod.rs +++ b/src/structs/mod.rs @@ -1 +1,2 @@ pub mod users; +pub mod exercise_types; diff --git a/src/v1/exercises/create.rs b/src/v1/exercises/create.rs new file mode 100644 index 0000000..a8eb977 --- /dev/null +++ b/src/v1/exercises/create.rs @@ -0,0 +1,59 @@ +use chrono::{DateTime, Utc}; +use extractors::users::UserId; +use sqlx::{query, query_as}; +use structs::exercise_types::ExerciseType; +use uuid::Uuid; + +use crate::*; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct CreateExerciseBody { + name: String, + // todo: make this an enum + exercise_type: ExerciseType, + description: String, +} + +#[derive(Debug, Deserialize, Serialize, Clone, sqlx::Type, PartialEq, sqlx::FromRow)] +pub struct Exercise { + pub id: Uuid, + pub name: String, + pub exercise_type: ExerciseType, + pub description: String, + pub author_id: Uuid, + pub official: bool, + pub created_at: DateTime, +} + +#[utoipa::path(post, path = "/create", responses((status = OK, body = String)), tag = super::EXERCISES_TAG)] +pub async fn create( + State(state): State, + UserId(user): UserId, + Json(body): Json, +) -> Result { + let is_admin = query!("SELECT is_admin FROM users WHERE id = $1", user) + .fetch_one(&*state.db) + .await? + .is_admin; + + if !is_admin { + return Err(AppError::Error(Errors::Unauthorized)); + } + // INSERT INTO users (id, created_at, updated_at, role) VALUES ($1, NOW(), NOW(), ($2::text)::user_role) RETURNING id, created_at, updated_at, role as "role:UserRole" + // + let insert = query_as!( + Exercise, + r#" + INSERT INTO exercises (name, exercise_type, description, author_id, official) + VALUES ($1, ($2::text)::exercise_type, $3, $4, $5) + RETURNING id, name, exercise_type, description, author_id, official, created_at + "#, + body.name, + body.exercise_type.to_string(), + body.description, + user, + false + ); + + todo!() +} diff --git a/src/v1/exercises/mod.rs b/src/v1/exercises/mod.rs new file mode 100644 index 0000000..175e517 --- /dev/null +++ b/src/v1/exercises/mod.rs @@ -0,0 +1,13 @@ +use crate::AppState; + +pub(super) use super::*; + +pub mod create; + +pub const EXERCISES_TAG: &str = "exercises"; + +pub fn router(state: AppState) -> OpenApiRouter { + OpenApiRouter::new() + .routes(routes!(create::create)) + .with_state(state) +} diff --git a/src/v1/mod.rs b/src/v1/mod.rs index 9a5715e..7d84948 100644 --- a/src/v1/mod.rs +++ b/src/v1/mod.rs @@ -2,3 +2,4 @@ pub(super) use utoipa_axum::router::OpenApiRouter; pub(super) use utoipa_axum::routes; pub mod auth; +pub mod exercises;