diff --git a/.changes/problem.md b/.changes/problem.md new file mode 100644 index 0000000..b00145c --- /dev/null +++ b/.changes/problem.md @@ -0,0 +1,5 @@ +--- +"algohub-server": patch:feat +--- + +Support for problems creation and initial submission. diff --git a/.cspell.json b/.cspell.json index 7baf1f6..b1e0852 100644 --- a/.cspell.json +++ b/.cspell.json @@ -3,6 +3,9 @@ "algohub", "chrono", "covector", + "farmfe", + "ICPC", + "serde", "dtolnay", "farmfe", "jbolda", diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index ee641a4..15fea36 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@nightly - name: Setup Surrealdb run: | docker run --rm --pull always -d \ diff --git a/.gitignore b/.gitignore index 35dd3bd..045c110 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,9 @@ *.sln *.sw? -# Datas +# Data Files /content +/database # Node.js /node_modules diff --git a/src/lib.rs b/src/lib.rs index 069f121..ea279e8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,8 +11,13 @@ pub mod utils { pub mod routes { pub mod account; pub mod index; + pub mod problem; } pub mod cors; +use models::response::Response; +use rocket::serde::json::Json; +pub type Result = std::result::Result>, models::error::Error>; + pub use crate::routes::index::rocket; diff --git a/src/models/problem.rs b/src/models/problem.rs index 2e8f205..5d9fa2d 100644 --- a/src/models/problem.rs +++ b/src/models/problem.rs @@ -1,25 +1,97 @@ use serde::{Deserialize, Serialize}; use surrealdb::sql::Thing; +use crate::routes::problem::ProblemData; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Sample { + pub input: String, + pub output: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +pub enum Mode { + #[default] + ICPC, + OI, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Problem { pub id: Option, - pub sequence: String, pub title: String, - pub content: String, - pub input: String, - pub output: String, - pub samples: Vec<(String, String)>, + pub description: String, + pub input: Option, + pub output: Option, + pub samples: Vec, + pub hint: Option, + + pub time_limit: u64, + pub memory_limit: u64, + pub test_cases: Vec, + + pub creator: Thing, + pub owner: Thing, + pub categories: Vec, + pub tags: Vec, + + pub mode: Mode, + pub private: bool, + + pub created_at: chrono::NaiveDateTime, + pub updated_at: chrono::NaiveDateTime, +} + +impl From> for Problem { + fn from(val: ProblemData<'_>) -> Self { + Problem { + id: None, + title: val.title.to_string(), + description: val.description.to_string(), + input: val.input, + output: val.output, + samples: val.samples, + hint: val.hint, + time_limit: val.time_limit, + memory_limit: val.memory_limit, + test_cases: val.test_cases, + creator: ("account", val.id).into(), + // owner: val.owner, + owner: ("account", val.id).into(), + categories: val.categories, + tags: val.tags, + mode: val.mode, + private: val.private, + created_at: chrono::Local::now().naive_local(), + updated_at: chrono::Local::now().naive_local(), + } + } +} + +#[derive(Serialize, Deserialize)] +pub struct ProblemDetail { + pub id: Thing, + + pub title: String, + pub description: String, + pub input: Option, + pub output: Option, + pub samples: Vec, + pub hint: Option, pub time_limit: i32, pub memory_limit: i32, - pub test_cases: Vec<(String, String)>, + pub test_cases: Vec, pub creator: Thing, + pub owner: Thing, pub categories: Vec, pub tags: Vec, + pub mode: Mode, + pub private: bool, + pub created_at: chrono::NaiveDateTime, pub updated_at: chrono::NaiveDateTime, } diff --git a/src/routes/account.rs b/src/routes/account.rs index 4e88612..dc5ecc0 100644 --- a/src/routes/account.rs +++ b/src/routes/account.rs @@ -21,6 +21,7 @@ use crate::{ response::{Empty, Response}, }, utils::{account, session}, + Result, }; #[derive(Serialize, Deserialize)] @@ -41,7 +42,7 @@ pub struct RegisterResponse { pub async fn register( db: &State>, register: Json, -) -> Result>, Error> { +) -> Result { match account::create(db, register.into_inner()).await { Ok(Some(account)) => { let token = match session::create(db, account.id.clone().unwrap()).await { @@ -81,10 +82,7 @@ pub struct ProfileData<'r> { } #[post("/profile", data = "")] -pub async fn profile( - db: &State>, - profile: Json>, -) -> Result>, Error> { +pub async fn profile(db: &State>, profile: Json>) -> Result { account::get_by_id::(db, profile.id) .await .map_err(|e| Error::ServerError(Json(e.to_string().into())))? @@ -116,7 +114,7 @@ pub async fn get_profile( db: &State>, id: &str, auth: Json>, -) -> Result>, Error> { +) -> Result { if !session::verify(db, id, auth.token).await { return Err(Error::Unauthorized(Json( "Failed to grant access permission".into(), @@ -153,13 +151,14 @@ pub struct Upload<'r> { #[derive(Serialize, Deserialize)] pub struct UploadResponse { pub uri: String, + pub path: String, } #[put("/content/upload", data = "")] pub async fn upload_content( db: &State>, data: Form>, -) -> Result>, Error> { +) -> Result { if !session::verify(db, data.id, data.token).await { return Err(Error::Unauthorized(Json( "Failed to grant access permission".into(), @@ -182,7 +181,7 @@ pub async fn upload_content( .await .map_err(|e| Error::ServerError(Json(e.to_string().into())))?; } - let file_name = format!("avatar.{}", file_extension); + let file_name = format!("{}.{}", uuid::Uuid::new_v4(), file_extension); let file_path = user_path.join(&file_name); let mut file = data @@ -203,6 +202,7 @@ pub async fn upload_content( message: "Content updated successfully".into(), data: Some(UploadResponse { uri: format!("/account/content/{}/{}", data.id, file_name), + path: format!("content/{}/{}", data.id, file_name), }), })) } @@ -212,7 +212,7 @@ pub async fn delete( db: &State>, id: &str, auth: Json>, -) -> Result>, Error> { +) -> Result { if !session::verify(db, id, auth.token).await { return Err(Error::Unauthorized(Json( "Failed to grant access permission".into(), @@ -248,10 +248,7 @@ pub struct LoginResponse { } #[post("/login", data = "")] -pub async fn login( - db: &State>, - login: Json>, -) -> Result>, Error> { +pub async fn login(db: &State>, login: Json>) -> Result { let session = session::authenticate(db, login.identity, login.password) .await .map_err(|e| Error::ServerError(Json(e.to_string().into())))? diff --git a/src/routes/index.rs b/src/routes/index.rs index 9b59959..94708a5 100644 --- a/src/routes/index.rs +++ b/src/routes/index.rs @@ -5,6 +5,8 @@ use anyhow::Result; use rocket::fs::NamedFile; use surrealdb::{engine::remote::ws::Ws, opt::auth::Root, Surreal}; +use super::problem; + #[get("/")] async fn index() -> Result { NamedFile::open("dist/index.html").await @@ -34,5 +36,6 @@ pub async fn rocket() -> rocket::Rocket { .attach(CORS) .mount("/", routes![index, files]) .mount("/account", account::routes()) + .mount("/problem", problem::routes()) .manage(db) } diff --git a/src/routes/problem.rs b/src/routes/problem.rs new file mode 100644 index 0000000..735f4bb --- /dev/null +++ b/src/routes/problem.rs @@ -0,0 +1,238 @@ +use std::time::Duration; + +use eval_stack::{compile::Language, config::JudgeOptions, judge::JudgeStatus}; +use rocket::{ + serde::json::Json, + tokio::{ + fs::{create_dir_all, File}, + io::AsyncWriteExt, + }, + State, +}; +use serde::{Deserialize, Serialize}; +use surrealdb::{engine::remote::ws::Client, Surreal}; + +use crate::{ + models::{ + error::Error, + problem::{Mode, Problem, ProblemDetail, Sample}, + response::Response, + }, + utils::{problem, session}, + Result, +}; + +#[derive(Serialize, Deserialize)] +#[serde(crate = "rocket::serde")] +pub struct ProblemData<'r> { + pub id: &'r str, + pub token: &'r str, + + pub title: &'r str, + pub description: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub input: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub output: Option, + pub samples: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub hint: Option, + + // pub owner: Thing, + pub time_limit: u64, + pub memory_limit: u64, + pub test_cases: Vec, + + pub categories: Vec, + pub tags: Vec, + + pub mode: Mode, + pub private: bool, +} + +#[derive(Serialize, Deserialize)] +#[serde(crate = "rocket::serde")] +pub struct ProblemResponse { + pub id: String, +} + +#[post("/create", data = "")] +pub async fn create( + db: &State>, + problem: Json>, +) -> Result { + if !session::verify(db, problem.id, problem.token).await { + return Err(Error::Unauthorized(Json("Invalid token".into()))); + } + + let problem = problem::create(db, problem.into_inner()) + .await + .map_err(|e| Error::ServerError(Json(e.to_string().into())))? + .ok_or(Error::ServerError(Json( + "Failed to create problem, please try again later.".into(), + )))?; + + Ok(Json(Response { + success: true, + message: "Problem created successfully".to_string(), + data: Some(ProblemResponse { + id: problem.id.unwrap().id.to_string(), + }), + })) +} + +#[derive(Serialize, Deserialize)] +#[serde(crate = "rocket::serde")] +pub struct Authenticate<'r> { + pub id: Option<&'r str>, + pub token: Option<&'r str>, +} + +#[post("/get/", data = "")] +pub async fn get( + db: &State>, + id: &str, + auth: Json>, +) -> Result { + let problem = problem::get::(db, id) + .await + .map_err(|e| Error::ServerError(Json(e.to_string().into())))? + .ok_or(Error::NotFound(Json( + "Problem with specified id not found".into(), + )))?; + + let has_permission = if problem.private { + if auth.id.is_none() || auth.token.is_none() { + false + } else { + if !session::verify(db, auth.id.unwrap(), auth.token.unwrap()).await { + false + } else { + auth.id.unwrap() == problem.owner.id.to_string() + } + } + } else { + true + }; + + if !has_permission { + return Err(Error::Unauthorized(Json( + "You have no permission to access this problem".into(), + ))); + } + + Ok(Json(Response { + success: true, + message: "Problem found".to_string(), + data: Some(problem), + })) +} + +#[derive(Serialize, Deserialize)] +pub struct SubmitData<'r> { + pub id: &'r str, + pub token: &'r str, + pub language: &'r str, + pub code: String, +} + +#[derive(Serialize, Deserialize)] +pub struct SubmitResponse { + pub status: String, +} + +#[post("/submit/", data = "")] +pub async fn submit( + db: &State>, + id: &str, + data: Json>, +) -> Result { + if !session::verify(db, data.id, data.token).await { + return Err(Error::Unauthorized(Json("Invalid token".into()))); + } + + let problem = problem::get::(db, id) + .await + .map_err(|e| Error::ServerError(Json(e.to_string().into())))? + .ok_or(Error::NotFound(Json( + "Problem with specified id not found".into(), + )))?; + + let options = JudgeOptions { + time_limit: Duration::from_secs(problem.time_limit), + memory_limit: problem.memory_limit, + }; + + let workspace = std::env::current_dir() + .unwrap() + .join("content") + .join(data.id) + .join("workspace") + .join(uuid::Uuid::new_v4().to_string()); + + if !workspace.exists() { + create_dir_all(&workspace) + .await + .map_err(|e| Error::ServerError(Json(e.to_string().into())))?; + } + + let language = match data.language { + "rust" => Language::Rust, + "c" => Language::C, + "cpp" => Language::CPP, + "python" => Language::Python, + _ => Language::Rust, + }; + + let source_file_path = workspace.join("source.code"); + File::create(&source_file_path) + .await + .map_err(|e| Error::ServerError(Json(e.to_string().into())))? + .write_all(data.code.as_bytes()) + .await + .unwrap(); + + println!("{:?}", problem.test_cases); + let result = eval_stack::case::run_test_cases( + language, + workspace, + source_file_path, + options, + problem + .test_cases + .iter() + .map(|s| (s.input.clone(), s.output.clone())) + .collect(), + true, + ) + .await + .map_err(|e| Error::ServerError(Json(e.to_string().into())))?; + println!("{:?}", result); + + let err = result + .iter() + .find(|&r| !matches!(r.status, JudgeStatus::Accepted)); + + if let Some(_e) = err { + Ok(Json(Response { + success: true, + message: "Unaccepted".to_string(), + data: Some(SubmitResponse { + status: "Wrong Answer".to_string(), + }), + })) + } else { + Ok(Json(Response { + success: true, + message: "Accepted".to_string(), + data: Some(SubmitResponse { + status: "Accepted".to_string(), + }), + })) + } +} + +pub fn routes() -> Vec { + use rocket::routes; + routes![create, get, submit] +} diff --git a/src/utils/problem.rs b/src/utils/problem.rs index 968f9fe..9a0ac0d 100644 --- a/src/utils/problem.rs +++ b/src/utils/problem.rs @@ -1,10 +1,14 @@ use anyhow::Result; +use serde::Deserialize; use surrealdb::{engine::remote::ws::Client, Surreal}; -use crate::models::problem::Problem; +use crate::{models::problem::Problem, routes::problem::ProblemData}; -pub async fn create(db: &Surreal, problem: Problem) -> Result> { - Ok(db.create("problem").content(problem).await?) +pub async fn create(db: &Surreal, problem: ProblemData<'_>) -> Result> { + Ok(db + .create("problem") + .content(Into::::into(problem)) + .await?) } pub async fn update(db: &Surreal, problem: Problem) -> Result> { @@ -18,9 +22,12 @@ pub async fn update(db: &Surreal, problem: Problem) -> Result, id: &str) -> Result> { - Ok(db.delete(("problem", id.to_string())).await?) + Ok(db.delete(("problem", id)).await?) } -pub async fn get(db: &Surreal, id: &str) -> Result> { - Ok(db.select(("problem", id.to_string())).await?) +pub async fn get(db: &Surreal, id: &str) -> Result> +where + for<'de> M: Deserialize<'de>, +{ + Ok(db.select(("problem", id)).await?) }