Compare commits
10 Commits
4caa0c8328
...
863dccd4fd
| Author | SHA1 | Date | |
|---|---|---|---|
| 863dccd4fd | |||
| f28eb6c422 | |||
| 0dc7dfa358 | |||
| 47c37e375d | |||
| 726092e31b | |||
| 982ab52e69 | |||
| 0ecd7c8a4c | |||
| ef722fe0d8 | |||
| 58b9923eb9 | |||
| c013f6bad8 |
66
Cargo.lock
generated
66
Cargo.lock
generated
@@ -70,11 +70,15 @@ name = "api"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
"axum",
|
||||
"chrono",
|
||||
"dotenv",
|
||||
"hmac",
|
||||
"jwt",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sqlx",
|
||||
"tokio",
|
||||
"tower",
|
||||
@@ -93,6 +97,18 @@ dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.83"
|
||||
@@ -201,6 +217,12 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
@@ -222,6 +244,15 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -274,6 +305,7 @@ dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
@@ -813,6 +845,21 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jwt"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6204285f77fe7d9784db3fdc449ecce1a0114927a51d5a41c4c7a292011c015f"
|
||||
dependencies = [
|
||||
"base64 0.13.1",
|
||||
"crypto-common",
|
||||
"digest",
|
||||
"hmac",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
@@ -1039,6 +1086,17 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
@@ -1616,7 +1674,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "64bb4714269afa44aef2755150a0fc19d756fb580a67db8885608cf02f47d06a"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bitflags",
|
||||
"byteorder",
|
||||
"bytes",
|
||||
@@ -1660,7 +1718,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6fa91a732d854c5d7726349bb4bb879bb9478993ceb764247660aee25f67c2f8"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bitflags",
|
||||
"byteorder",
|
||||
"chrono",
|
||||
@@ -2027,6 +2085,10 @@ name = "uuid"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
|
||||
@@ -20,6 +20,10 @@ sqlx = { version = "0.8.2", features = [
|
||||
"macros",
|
||||
"uuid",
|
||||
] }
|
||||
uuid = "1.11.0"
|
||||
chrono = "0.4.39"
|
||||
uuid = { version = "1.4.1", features = ["serde", "v4"] }
|
||||
chrono = { version = "0.4.26", features = ["serde"] }
|
||||
dotenv = "0.15.0"
|
||||
argon2 = "0.5.3"
|
||||
jwt = "0.16.0"
|
||||
hmac = "0.12.1"
|
||||
sha2 = "0.10.8"
|
||||
|
||||
3180
data/data.txt
Normal file
3180
data/data.txt
Normal file
File diff suppressed because it is too large
Load Diff
3458
data/muscle-data.json
Normal file
3458
data/muscle-data.json
Normal file
File diff suppressed because it is too large
Load Diff
73
data/parse.ts
Normal file
73
data/parse.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import * as fs from 'fs';
|
||||
|
||||
interface MuscleEntry {
|
||||
muscle: string;
|
||||
location: string;
|
||||
origin: string;
|
||||
insertion: string;
|
||||
artery: string;
|
||||
nerve: string;
|
||||
action: string;
|
||||
antagonist: string;
|
||||
o: number;
|
||||
ta: number;
|
||||
}
|
||||
|
||||
function parseMuscleTable(table: string): MuscleEntry[] {
|
||||
const rows = table.split(/\|- style="vertical-align: top;"/).slice(1); // Split rows and ignore header
|
||||
|
||||
return rows.map(row => {
|
||||
const columns = row.split('\n').map(chopUntilPipe);
|
||||
columns.shift();
|
||||
// .replace(/\{\{[^}]+\}\}/g, '') // Remove templates like {{...}}
|
||||
// .replace(/<[^>]+>/g, '') // Remove HTML tags
|
||||
// .split('|').slice(1); // Split columns and ignore the first empty element
|
||||
|
||||
return {
|
||||
muscle: extractText(columns[0]),
|
||||
location: extractText(columns[1]),
|
||||
origin: extractText(columns[2]),
|
||||
insertion: extractText(columns[3]),
|
||||
artery: extractText(columns[4]),
|
||||
nerve: extractText(columns[5]),
|
||||
action: extractText(columns[6]),
|
||||
antagonist: extractText(columns[7] || '?'),
|
||||
o: parseInt(columns[8] || '0', 10),
|
||||
ta: parseInt(columns[9] || '0', 10),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function extractText(input: string): string {
|
||||
return input
|
||||
.replace(/\[\[(.*?)\|(.*?)\]\]/g, "$2")
|
||||
.replace(/\[\[(.*?)\]\]/g, "$1")
|
||||
.replace(/\<ref[^>]*>.*?<\/ref>/g, '') // Remove references like <ref>...</ref>
|
||||
.trim();
|
||||
}
|
||||
|
||||
|
||||
function chopUntilPipe(input: string): string {
|
||||
const pipeIndex = input.indexOf('|');
|
||||
return pipeIndex >= 0 ? input.slice(pipeIndex + 1) : input;
|
||||
}
|
||||
|
||||
// Read data from file
|
||||
fs.readFile('data.txt', 'utf8', (err, data) => {
|
||||
if (err) {
|
||||
console.error('Error reading file:', err);
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedData = parseMuscleTable(data);
|
||||
// console.log(parsedData[0]);
|
||||
console.log(parsedData);
|
||||
|
||||
fs.writeFile('muscle-data.json', JSON.stringify(parsedData, null, 2), (err) => {
|
||||
if (err) {
|
||||
console.error('Error writing file:', err);
|
||||
return;
|
||||
}
|
||||
console.log('Data written to muscle-data.json');
|
||||
});
|
||||
});
|
||||
9
data/test.ts
Normal file
9
data/test.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
const gae = "[[Occipitalis muscle|occipitalis]]";
|
||||
|
||||
const gae2 = "occipitalis";
|
||||
|
||||
function clean(str: string) {
|
||||
return str.replace(/\[\[(.*?)\|(.*?)\]\]/g, "$2");
|
||||
}
|
||||
|
||||
console.log(clean(gae));
|
||||
109
gen.ts
Normal file
109
gen.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
// Define the structure of your data
|
||||
interface Muscle {
|
||||
colloquial_name: string;
|
||||
scientific_name: string;
|
||||
}
|
||||
|
||||
interface MuscleGroup {
|
||||
major_group: string;
|
||||
group_name: string;
|
||||
muscles: Muscle[];
|
||||
}
|
||||
|
||||
// Your JSON data
|
||||
const data: MuscleGroup[] = [
|
||||
{
|
||||
major_group: "Upper Body",
|
||||
group_name: "Chest",
|
||||
muscles: [
|
||||
{ colloquial_name: "Upper Pecs", scientific_name: "Clavicular Pectoralis Major" },
|
||||
{ colloquial_name: "Pecs", scientific_name: "Sternal Pectoralis Major" },
|
||||
]
|
||||
},
|
||||
{
|
||||
major_group: "Upper Body",
|
||||
group_name: "Back",
|
||||
muscles: [
|
||||
{ colloquial_name: "Lats", scientific_name: "Latissimus dorsi" },
|
||||
{ colloquial_name: "Traps", scientific_name: "Trapezius" },
|
||||
{ colloquial_name: "Rhomboids", scientific_name: "Rhomboideus major" },
|
||||
{ colloquial_name: "Spinal Erectors", scientific_name: "Erector spinae" },
|
||||
{ colloquial_name: "Teres Major", scientific_name: "Teres major" }
|
||||
]
|
||||
},
|
||||
{
|
||||
major_group: "Upper Body",
|
||||
group_name: "Shoulders",
|
||||
muscles: [
|
||||
{ colloquial_name: "Front Delts", scientific_name: "Anterior deltoid" },
|
||||
{ colloquial_name: "Rear Delts", scientific_name: "Posterior deltoid" },
|
||||
{ colloquial_name: "Side Delts", scientific_name: "Lateral deltoid" },
|
||||
{ colloquial_name: "Rotator Cuff", scientific_name: "Rotator cuff" }
|
||||
]
|
||||
},
|
||||
{
|
||||
major_group: "Upper Body",
|
||||
group_name: "Arms",
|
||||
muscles: [
|
||||
{ colloquial_name: "Biceps", scientific_name: "Biceps brachii" },
|
||||
{ colloquial_name: "Brachialis", scientific_name: "Brachialis" },
|
||||
{ colloquial_name: "Triceps", scientific_name: "Triceps brachii" },
|
||||
{ colloquial_name: "Brachioradialis", scientific_name: "Brachioradialis" },
|
||||
{ colloquial_name: "Forearm Flexors/Extensors", scientific_name: "Flexor and extensor muscles of the forearm" }
|
||||
]
|
||||
},
|
||||
{
|
||||
major_group: "Core",
|
||||
group_name: "Abdominals",
|
||||
muscles: [
|
||||
{ colloquial_name: "Abs", scientific_name: "Rectus abdominis" },
|
||||
{ colloquial_name: "Transverse Abs", scientific_name: "Transversus abdominis" },
|
||||
{ colloquial_name: "External Obliques", scientific_name: "Obliquus externus abdominis" },
|
||||
{ colloquial_name: "Internal Obliques", scientific_name: "Obliquus internus abdominis" }
|
||||
]
|
||||
},
|
||||
{
|
||||
major_group: "Core",
|
||||
group_name: "Lower Back",
|
||||
muscles: [
|
||||
{ colloquial_name: "QL", scientific_name: "Quadratus lumborum" },
|
||||
]
|
||||
},
|
||||
{
|
||||
major_group: "Lower Body",
|
||||
group_name: "Legs",
|
||||
muscles: [
|
||||
{ colloquial_name: "Quads", scientific_name: "Quadriceps femoris" },
|
||||
{ colloquial_name: "Hamstrings", scientific_name: "Hamstring muscles" },
|
||||
{ colloquial_name: "Glutes", scientific_name: "Gluteus maximus" },
|
||||
{ colloquial_name: "Glute Med", scientific_name: "Gluteus medius" },
|
||||
{ colloquial_name: "Glute Min", scientific_name: "Gluteus minimus" },
|
||||
{ colloquial_name: "Adductors", scientific_name: "Adductor muscles" },
|
||||
{ colloquial_name: "Abductors", scientific_name: "Abductor muscles" }
|
||||
]
|
||||
},
|
||||
{
|
||||
major_group: "Lower Body",
|
||||
group_name: "Calves",
|
||||
muscles: [
|
||||
{ colloquial_name: "Gastrocs", scientific_name: "Gastrocnemius" },
|
||||
{ colloquial_name: "Soleus", scientific_name: "Soleus" }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// Generate SQL INSERT statements
|
||||
function generateSQL(data: MuscleGroup[]): string {
|
||||
let sql = "";
|
||||
|
||||
data.forEach((group) => {
|
||||
group.muscles.forEach((muscle) => {
|
||||
sql += `INSERT INTO muscles (major_group, minor_group, name, scientific_name) VALUES ('${group.major_group}', '${group.group_name}', '${muscle.colloquial_name}', '${muscle.scientific_name}');\n`;
|
||||
});
|
||||
});
|
||||
|
||||
return sql;
|
||||
}
|
||||
|
||||
const sqlStatements = generateSQL(data);
|
||||
console.log(sqlStatements);
|
||||
7
migrations/20241210224609_official_exercises.sql
Normal file
7
migrations/20241210224609_official_exercises.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
-- Add migration script here
|
||||
|
||||
ALTER TABLE exercises
|
||||
ADD COLUMN official BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN author_id UUID REFERENCES users(id),
|
||||
ADD COLUMN description TEXT,
|
||||
ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT NOW();
|
||||
9
migrations/20241211081452_scientific_names.sql
Normal file
9
migrations/20241211081452_scientific_names.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- Add migration script here
|
||||
|
||||
ALTER TABLE muscles
|
||||
ADD COLUMN scientific_name VARCHAR(255),
|
||||
ADD COLUMN major_group VARCHAR(255),
|
||||
ADD COLUMN minor_group VARCHAR(255) NOT NULL;
|
||||
|
||||
DROP TABLE muscle_bodypart_relations;
|
||||
DROP TABLE body_parts;
|
||||
@@ -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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
44
src/extractors/jwt.rs
Normal file
44
src/extractors/jwt.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
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 JWT(JWTClaims);
|
||||
|
||||
#[async_trait]
|
||||
impl<S> FromRequestParts<S> for JWT
|
||||
where
|
||||
AppState: FromRef<S>,
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, s: &S) -> Result<Self, Self::Rejection> {
|
||||
let state = AppState::from_ref(s);
|
||||
|
||||
let jwt_string = parts
|
||||
.headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.ok_or(AppError::Error(Errors::Unauthorized))?
|
||||
.to_str()
|
||||
.map_err(|_| AppError::Error(Errors::Unauthorized))?
|
||||
.strip_prefix("Bearer ")
|
||||
.ok_or(AppError::Error(Errors::Unauthorized))?;
|
||||
|
||||
let claims: JWTClaims = jwt_string
|
||||
.verify_with_key(&state.jwt_key)
|
||||
.map_err(|_| AppError::Error(Errors::Unauthorized))?;
|
||||
|
||||
Ok(JWT(claims))
|
||||
}
|
||||
}
|
||||
2
src/extractors/mod.rs
Normal file
2
src/extractors/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod users;
|
||||
pub mod jwt;
|
||||
46
src/extractors/users.rs
Normal file
46
src/extractors/users.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
use crate::*;
|
||||
use axum::{
|
||||
async_trait,
|
||||
extract::{FromRef, FromRequestParts},
|
||||
http::{header, request::Parts},
|
||||
};
|
||||
|
||||
use jwt::VerifyWithKey;
|
||||
use sqlx::types::Uuid;
|
||||
use util::auth::JWTClaims;
|
||||
|
||||
/// 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<S> FromRequestParts<S> for UserId
|
||||
where
|
||||
AppState: FromRef<S>,
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, s: &S) -> Result<Self, Self::Rejection> {
|
||||
let state = AppState::from_ref(s);
|
||||
|
||||
let jwt_string = parts
|
||||
.headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.ok_or(AppError::Error(Errors::Unauthorized))?
|
||||
.to_str()
|
||||
.map_err(|_| AppError::Error(Errors::Unauthorized))?
|
||||
.strip_prefix("Bearer ")
|
||||
.ok_or(AppError::Error(Errors::Unauthorized))?;
|
||||
|
||||
let claims: JWTClaims = jwt_string
|
||||
.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))
|
||||
}
|
||||
}
|
||||
18
src/main.rs
18
src/main.rs
@@ -1,17 +1,20 @@
|
||||
use std::{net::Ipv4Addr, sync::Arc};
|
||||
use hmac::{Hmac, Mac};
|
||||
use tokio::net::TcpListener;
|
||||
use utoipa::OpenApi;
|
||||
use utoipa_axum::{router::OpenApiRouter, routes};
|
||||
use utoipa_swagger_ui::SwaggerUi;
|
||||
|
||||
// tags
|
||||
use v1::auth::AUTH_TAG;
|
||||
use v1::{auth::AUTH_TAG, exercises::EXERCISES_TAG, muscles::MUSCLES_TAG};
|
||||
|
||||
mod v1;
|
||||
mod structs;
|
||||
mod state;
|
||||
mod db;
|
||||
mod error;
|
||||
mod util;
|
||||
mod extractors;
|
||||
|
||||
pub(crate) use anyhow::Context;
|
||||
pub(crate) use axum::extract::{Json, State};
|
||||
@@ -24,6 +27,8 @@ pub(crate) use utoipa::ToSchema;
|
||||
#[openapi(
|
||||
tags(
|
||||
(name = AUTH_TAG, description = "Authentication API endpoints"),
|
||||
(name = EXERCISES_TAG, description = "Exercise API endpoints"),
|
||||
(name = MUSCLES_TAG, description = "Muscle API endpoints"),
|
||||
// (name = CUSTOMER_TAG, description = "Customer API endpoints"),
|
||||
// (name = ORDER_TAG, description = "Order API endpoints")
|
||||
),
|
||||
@@ -56,16 +61,19 @@ async fn index() -> &'static str {
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
dotenv::dotenv().ok();
|
||||
let db = db::db().await?;
|
||||
let state = state::AppState { db: Arc::new(db) };
|
||||
|
||||
let port: u16 = std::env::var("PORT").unwrap_or_else(|_| "8080".to_string()).parse()?;
|
||||
let jwt_secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "secret".to_string());
|
||||
|
||||
let db = db::db().await?;
|
||||
let state = state::AppState { db: Arc::new(db),
|
||||
jwt_key: Hmac::new_from_slice(jwt_secret.as_bytes()).context("Failed to create HMAC")?
|
||||
};
|
||||
|
||||
let (router, api) = OpenApiRouter::with_openapi(ApiDoc::openapi())
|
||||
.routes(routes!(health_check))
|
||||
.routes(routes!(index))
|
||||
.with_state(state.clone())
|
||||
.nest("/api/v1/auth", v1::auth::router(state.clone()))
|
||||
.nest("/api/v1", v1::router(state.clone()))
|
||||
.split_for_parts();
|
||||
|
||||
let router = router.merge(SwaggerUi::new("/docs").url("/docs/openapi.json", api));
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use hmac::Hmac;
|
||||
use sha2::Sha256;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub db: DB,
|
||||
// pub jwt_secret: String,
|
||||
pub jwt_key: Hmac<Sha256>,
|
||||
}
|
||||
|
||||
pub type DB = Arc<sqlx::Pool<sqlx::Postgres>>;
|
||||
|
||||
55
src/structs/exercise_types.rs
Normal file
55
src/structs/exercise_types.rs
Normal file
@@ -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<Self, Self::Err> {
|
||||
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")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
pub mod users;
|
||||
pub mod exercise_types;
|
||||
|
||||
49
src/util/auth.rs
Normal file
49
src/util/auth.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::*;
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct JWTClaims {
|
||||
pub sub: Uuid,
|
||||
pub iat: i64,
|
||||
pub exp: i64,
|
||||
|
||||
pub username: String,
|
||||
pub real_name: String,
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
impl JWTClaims {
|
||||
pub fn new(sub: Uuid, username: String, real_name: String, email: String) -> Self {
|
||||
let iat = Utc::now().timestamp();
|
||||
let exp = iat + 60 * 60 * 24 * 7;
|
||||
Self {
|
||||
sub,
|
||||
iat,
|
||||
exp,
|
||||
username,
|
||||
real_name,
|
||||
email,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new JWT Claims with a custom expiration time. Expiration time is added to current time.
|
||||
pub fn new_with_exp(
|
||||
sub: Uuid,
|
||||
username: String,
|
||||
real_name: String,
|
||||
email: String,
|
||||
exp: i64,
|
||||
) -> Self {
|
||||
let iat = Utc::now().timestamp();
|
||||
Self {
|
||||
sub,
|
||||
iat,
|
||||
exp: iat + exp,
|
||||
username,
|
||||
real_name,
|
||||
email,
|
||||
}
|
||||
}
|
||||
}
|
||||
1
src/util/mod.rs
Normal file
1
src/util/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod auth;
|
||||
48
src/v1/auth/login.rs
Normal file
48
src/v1/auth/login.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use crate::*;
|
||||
use argon2::{
|
||||
password_hash::{PasswordHash, PasswordVerifier},
|
||||
Argon2,
|
||||
};
|
||||
use jwt::SignWithKey;
|
||||
use sqlx::query;
|
||||
use util::auth::JWTClaims;
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct LoginBody {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
/// Login
|
||||
#[utoipa::path(post, path = "/login", responses((status = OK, body = String)), tag = super::AUTH_TAG)]
|
||||
pub async fn login(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<LoginBody>,
|
||||
) -> Result<String, AppError> {
|
||||
let user = query!(
|
||||
"SELECT id, real_name, username, email, password_hash, is_admin, created_at
|
||||
FROM users
|
||||
WHERE username = $1",
|
||||
body.username,
|
||||
)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
let argon2 = Argon2::default();
|
||||
let hash = PasswordHash::new(&body.password).expect("Password hashing failed");
|
||||
|
||||
if !argon2
|
||||
.verify_password(body.password.as_bytes(), &hash)
|
||||
.is_ok()
|
||||
{
|
||||
return Err(AppError::Error(Errors::Unauthorized));
|
||||
}
|
||||
|
||||
let claims = JWTClaims::new(user.id, user.username, user.real_name, user.email);
|
||||
|
||||
let token_str = claims
|
||||
.sign_with_key(&state.jwt_key)
|
||||
.context("Failed to sign JWT")?;
|
||||
|
||||
Ok(token_str)
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
use crate::state::AppState;
|
||||
use crate::AppState;
|
||||
|
||||
pub(super) use super::*;
|
||||
|
||||
pub mod signup;
|
||||
pub mod login;
|
||||
|
||||
pub const AUTH_TAG: &str = "auth";
|
||||
|
||||
pub fn router(state: AppState) -> OpenApiRouter {
|
||||
pub(super) fn router(state: AppState) -> OpenApiRouter {
|
||||
OpenApiRouter::new()
|
||||
.routes(routes!(signup::signup))
|
||||
.routes(routes!(login::login))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
use crate::*;
|
||||
use sqlx::query;
|
||||
|
||||
use argon2::{
|
||||
password_hash::{rand_core::OsRng, PasswordHasher, SaltString},
|
||||
Argon2,
|
||||
};
|
||||
|
||||
use jwt::SignWithKey;
|
||||
use util::auth::JWTClaims;
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct SignupBody {
|
||||
real_name: String,
|
||||
@@ -10,11 +18,19 @@ pub struct SignupBody {
|
||||
}
|
||||
|
||||
/// Sign up
|
||||
#[utoipa::path(get, path = "/signup", responses((status = OK, body = String)), tag = super::AUTH_TAG)]
|
||||
#[utoipa::path(post, path = "/signup", responses((status = OK, body = String)), tag = super::AUTH_TAG)]
|
||||
pub async fn signup(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<SignupBody>,
|
||||
) -> Result<String, AppError> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::default();
|
||||
|
||||
let hash = argon2
|
||||
.hash_password(body.password.as_bytes(), &salt)
|
||||
.expect("Password hashing failed")
|
||||
.to_string();
|
||||
|
||||
let user = query!(
|
||||
"INSERT INTO users (real_name, username, email, password_hash)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
@@ -22,10 +38,16 @@ pub async fn signup(
|
||||
body.real_name,
|
||||
body.username,
|
||||
body.email,
|
||||
body.password,
|
||||
hash
|
||||
)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(user.id.to_string())
|
||||
let claims = JWTClaims::new(user.id, user.username, user.real_name, user.email);
|
||||
|
||||
let token_str = claims
|
||||
.sign_with_key(&state.jwt_key)
|
||||
.context("Failed to sign JWT")?;
|
||||
|
||||
Ok(token_str)
|
||||
}
|
||||
|
||||
57
src/v1/exercises/create.rs
Normal file
57
src/v1/exercises/create.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
use chrono::NaiveDateTime;
|
||||
use extractors::users::UserId;
|
||||
use sqlx::query;
|
||||
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,
|
||||
body_parts: Vec<String>,
|
||||
primary_muscles: Vec<String>,
|
||||
secondary_muscles: Vec<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: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[utoipa::path(post, path = "/create", responses((status = OK, body = String)), tag = super::EXERCISES_TAG)]
|
||||
pub async fn create(
|
||||
State(state): State<AppState>,
|
||||
UserId(user): UserId,
|
||||
Json(body): Json<CreateExerciseBody>,
|
||||
) -> Result<String, AppError> {
|
||||
let is_admin = query!("SELECT is_admin FROM users WHERE id = $1", user)
|
||||
.fetch_one(&*state.db)
|
||||
.await?
|
||||
.is_admin;
|
||||
|
||||
query!(
|
||||
r#"
|
||||
INSERT INTO exercises (name, exercise_type, official, author_id, description)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
"#,
|
||||
body.name,
|
||||
body.exercise_type as ExerciseType,
|
||||
is_admin,
|
||||
user,
|
||||
body.description
|
||||
)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
todo!()
|
||||
}
|
||||
13
src/v1/exercises/mod.rs
Normal file
13
src/v1/exercises/mod.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
use crate::AppState;
|
||||
|
||||
pub(super) use super::*;
|
||||
|
||||
pub mod create;
|
||||
|
||||
pub const EXERCISES_TAG: &str = "exercises";
|
||||
|
||||
pub(super) fn router(state: AppState) -> OpenApiRouter {
|
||||
OpenApiRouter::new()
|
||||
.routes(routes!(create::create))
|
||||
.with_state(state)
|
||||
}
|
||||
@@ -1,4 +1,16 @@
|
||||
pub(super) use utoipa_axum::router::OpenApiRouter;
|
||||
pub(super) use utoipa_axum::routes;
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
pub mod auth;
|
||||
pub mod exercises;
|
||||
pub mod muscles;
|
||||
|
||||
pub fn router(state: AppState) -> OpenApiRouter {
|
||||
OpenApiRouter::new()
|
||||
.with_state(state.clone())
|
||||
.nest("/auth", auth::router(state.clone()))
|
||||
.nest("/exercises", exercises::router(state.clone()))
|
||||
.nest("/muscles", muscles::router(state.clone()))
|
||||
}
|
||||
|
||||
40
src/v1/muscles/create.rs
Normal file
40
src/v1/muscles/create.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use extractors::users::UserId;
|
||||
use sqlx::query;
|
||||
|
||||
use crate::*;
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct CreateMuscleBody {
|
||||
name: String,
|
||||
scientific_name: String,
|
||||
major_group: String,
|
||||
minor_group: String,
|
||||
}
|
||||
|
||||
#[utoipa::path(post, path = "/create", responses((status = OK, body = String)), tag = super::MUSCLES_TAG)]
|
||||
pub async fn create(
|
||||
State(state): State<AppState>,
|
||||
UserId(user_id): UserId,
|
||||
Json(body): Json<CreateMuscleBody>,
|
||||
) -> Result<String, AppError> {
|
||||
let is_admin = query!("SELECT is_admin FROM users WHERE id = $1", user_id)
|
||||
.fetch_one(&*state.db)
|
||||
.await?
|
||||
.is_admin;
|
||||
|
||||
if !is_admin {
|
||||
return Err(AppError::Error(Errors::Unauthorized));
|
||||
}
|
||||
|
||||
let out = query!(
|
||||
"INSERT INTO muscles (name, scientific_name, major_group, minor_group) VALUES ($1, $2, $3, $4) RETURNING id",
|
||||
body.name,
|
||||
body.scientific_name,
|
||||
body.major_group,
|
||||
body.minor_group,
|
||||
)
|
||||
.fetch_one(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(out.id.to_string())
|
||||
}
|
||||
23
src/v1/muscles/get_all.rs
Normal file
23
src/v1/muscles/get_all.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
// use extractors::users::UserId;
|
||||
use sqlx::query_as;
|
||||
|
||||
use crate::*;
|
||||
|
||||
#[derive(Serialize, Deserialize, ToSchema)]
|
||||
pub struct Muscle {
|
||||
id: String,
|
||||
name: String,
|
||||
scientific_name: Option<String>,
|
||||
major_group: Option<String>,
|
||||
minor_group: String,
|
||||
}
|
||||
|
||||
#[utoipa::path(get, path = "/all", responses((status = OK, body = Muscle)), tag = super::MUSCLES_TAG)]
|
||||
pub async fn get_all(State(state): State<AppState>) -> Result<Json<Vec<Muscle>>, AppError> {
|
||||
let muscles = query_as!(Muscle, "SELECT * FROM muscles")
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(muscles))
|
||||
}
|
||||
|
||||
15
src/v1/muscles/mod.rs
Normal file
15
src/v1/muscles/mod.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
use crate::AppState;
|
||||
|
||||
pub(super) use super::*;
|
||||
|
||||
pub mod create;
|
||||
pub mod get_all;
|
||||
|
||||
pub const MUSCLES_TAG: &str = "muscles";
|
||||
|
||||
pub(super) fn router(state: AppState) -> OpenApiRouter {
|
||||
OpenApiRouter::new()
|
||||
.routes(routes!(create::create))
|
||||
.routes(routes!(get_all::get_all))
|
||||
.with_state(state)
|
||||
}
|
||||
Reference in New Issue
Block a user