diff --git a/.changes/refactor-asset.md b/.changes/refactor-asset.md new file mode 100644 index 0000000..22a8662 --- /dev/null +++ b/.changes/refactor-asset.md @@ -0,0 +1,5 @@ +--- +"algohub-server": patch:refactor +--- + +Full rewrite all api endpoints of assets. diff --git a/src/lib.rs b/src/lib.rs index febc4a6..0960dfa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ extern crate rocket; pub mod models; pub mod utils { pub mod account; + pub mod asset; pub mod contest; pub mod organization; pub mod problem; @@ -13,11 +14,12 @@ pub mod utils { pub mod routes { pub mod account; + pub mod asset; pub mod contest; pub mod index; pub mod organization; - pub mod submission; pub mod problem; + pub mod submission; } pub mod cors; diff --git a/src/models/account.rs b/src/models/account.rs index d97266e..ae3bf55 100644 --- a/src/models/account.rs +++ b/src/models/account.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use surrealdb::sql::Thing; -#[derive(Debug, Default, Clone, Serialize, Deserialize)] +#[derive(Default, Serialize, Deserialize)] pub struct Account { pub id: Option, pub username: String, @@ -28,6 +28,20 @@ pub struct Account { pub updated_at: chrono::NaiveDateTime, } +#[derive(Serialize, Deserialize)] +#[serde(crate = "rocket::serde")] +pub struct Register { + pub username: String, + pub email: String, + pub password: String, +} + +#[derive(Deserialize)] +pub struct Login<'r> { + pub identity: &'r str, + pub password: &'r str, +} + #[derive(Serialize, Deserialize, Clone, Debug)] pub struct Profile { #[serde(skip_serializing_if = "Option::is_none")] diff --git a/src/models/asset.rs b/src/models/asset.rs index 0e110ad..c3d82f9 100644 --- a/src/models/asset.rs +++ b/src/models/asset.rs @@ -1,11 +1,27 @@ use std::path::PathBuf; +use rocket::fs::TempFile; use serde::{Deserialize, Serialize}; use surrealdb::sql::Thing; +use super::{Credentials, UserRecordId}; + #[derive(Serialize, Deserialize)] pub struct Asset { pub id: Option, pub name: String, + pub owner: Thing, pub path: PathBuf, } + +#[derive(FromForm)] +pub struct CreateAsset<'a> { + pub auth: Credentials<'a>, + pub owner: UserRecordId, + pub file: TempFile<'a>, +} + +#[derive(Serialize, Deserialize)] +pub struct UserContent { + pub id: String, +} diff --git a/src/models/error.rs b/src/models/error.rs index a5f5f44..3661224 100644 --- a/src/models/error.rs +++ b/src/models/error.rs @@ -34,8 +34,8 @@ pub enum Error { Forbidden(Json), } -impl From for Error { - fn from(message: String) -> Self { - Error::ServerError(Json(ErrorResponse::from(message))) +impl From for Error { + fn from(e: T) -> Self { + Error::ServerError(Json(e.into())) } } diff --git a/src/models/shared.rs b/src/models/shared.rs index 388f4ff..c8e40f9 100644 --- a/src/models/shared.rs +++ b/src/models/shared.rs @@ -1,3 +1,4 @@ +use rocket::form::{FromForm, FromFormField}; use serde::{Deserialize, Serialize}; use surrealdb::sql::Thing; @@ -12,6 +13,28 @@ pub struct UserRecordId { pub id: String, } +#[rocket::async_trait] +impl<'v> FromFormField<'v> for UserRecordId { + fn from_value(field: rocket::form::ValueField<'v>) -> rocket::form::Result<'v, Self> { + if let Some((tb, id)) = field.value.split_once(':') { + Ok(UserRecordId { + tb: tb.to_string(), + id: id.to_string(), + }) + } else { + Err(field.unexpected())? + } + } + + async fn from_data(field: rocket::form::DataField<'v, '_>) -> rocket::form::Result<'v, Self> { + Err(field.unexpected())? + } + + fn default() -> Option { + None + } +} + impl From for UserRecordId { fn from(thing: Thing) -> Self { UserRecordId { @@ -32,7 +55,7 @@ pub struct UpdateAt { pub updated_at: chrono::NaiveDateTime, } -#[derive(Serialize, Deserialize)] +#[derive(FromForm, Serialize, Deserialize)] #[serde(crate = "rocket::serde")] pub struct Credentials<'c> { pub id: &'c str, diff --git a/src/routes/account.rs b/src/routes/account.rs index 240415b..59b5575 100644 --- a/src/routes/account.rs +++ b/src/routes/account.rs @@ -1,49 +1,25 @@ -use std::path::{Path, PathBuf}; +use std::path::Path; -use rocket::{ - form::Form, - fs::{NamedFile, TempFile}, - get, post, put, - serde::json::Json, - tokio::{ - self, - fs::{create_dir_all, remove_dir_all, File}, - }, - State, -}; +use rocket::{get, post, serde::json::Json, tokio::fs::remove_dir_all, State}; use serde::{Deserialize, Serialize}; use surrealdb::{engine::remote::ws::Client, Surreal}; use crate::{ models::{ - account::Profile, + account::{Login, Profile, Register}, error::{Error, ErrorResponse}, response::{Empty, Response}, - Record, Token, + OwnedCredentials, Record, Token, }, utils::{account, session}, Result, }; -#[derive(Serialize, Deserialize)] -pub struct RegisterData { - pub username: String, - pub email: String, - pub password: String, -} - -#[derive(Serialize, Deserialize, Debug)] -#[serde(crate = "rocket::serde")] -pub struct RegisterResponse { - pub id: String, - pub token: String, -} - #[post("/create", data = "")] pub async fn register( db: &State>, - register: Json, -) -> Result { + register: Json, +) -> Result { match account::create(db, register.into_inner()).await { Ok(Some(account)) => { let token = match session::create(db, account.id.clone().unwrap()).await { @@ -54,7 +30,7 @@ pub async fn register( Ok(Response { success: true, message: format!("Account with id {} created successfully", &id), - data: Some(RegisterResponse { id, token }), + data: Some(OwnedCredentials { id, token }), } .into()) } @@ -83,7 +59,10 @@ pub struct MergeProfile<'r> { } #[post("/profile", data = "")] -pub async fn profile(db: &State>, profile: Json>) -> Result { +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())))? @@ -119,77 +98,6 @@ pub async fn get_profile(db: &State>, id: &str) -> Result")] -pub async fn content(file: PathBuf) -> Option { - NamedFile::open(Path::new("content/").join(file)).await.ok() -} - -#[derive(FromForm)] -pub struct Upload<'r> { - pub id: &'r str, - pub token: &'r str, - pub file: TempFile<'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 { - if !session::verify(db, data.id, data.token).await { - return Err(Error::Unauthorized(Json( - "Failed to grant access permission".into(), - ))); - } - - let file_extension = data - .file - .content_type() - .and_then(|ext| ext.extension().map(ToString::to_string)) - .ok_or_else(|| Error::BadRequest(Json("Invalid file type".into())))?; - - let user_path = std::env::current_dir() - .unwrap() - .join("content") - .join(data.id); - - if !user_path.exists() { - create_dir_all(&user_path) - .await - .map_err(|e| Error::ServerError(Json(e.to_string().into())))?; - } - let file_name = format!("{}.{}", uuid::Uuid::new_v4(), file_extension); - let file_path = user_path.join(&file_name); - - let mut file = data - .file - .open() - .await - .map_err(|e| Error::ServerError(Json(format!("Failed to open file: {}", e).into())))?; - let mut output_file = File::create(&file_path) - .await - .map_err(|e| Error::ServerError(Json(format!("Failed to create file: {}", e).into())))?; - - tokio::io::copy(&mut file, &mut output_file) - .await - .map_err(|e| Error::ServerError(Json(format!("Failed to save file: {}", e).into())))?; - - Ok(Json(Response { - success: true, - message: "Content updated successfully".into(), - data: Some(UploadResponse { - uri: format!("/account/content/{}/{}", data.id, file_name), - path: format!("content/{}/{}", data.id, file_name), - }), - })) -} - #[post("/delete/", data = "")] pub async fn delete(db: &State>, id: &str, auth: Json>) -> Result { if !session::verify(db, id, auth.token).await { @@ -214,12 +122,6 @@ pub async fn delete(db: &State>, id: &str, auth: Json> .into()) } -#[derive(Deserialize)] -pub struct Login<'r> { - pub identity: &'r str, - pub password: &'r str, -} - #[derive(Serialize, Deserialize)] pub struct LoginResponse { pub id: String, @@ -245,13 +147,5 @@ pub async fn login(db: &State>, login: Json>) -> Resul pub fn routes() -> Vec { use rocket::routes; - routes![ - register, - profile, - get_profile, - content, - upload_content, - delete, - login - ] + routes![register, profile, get_profile, delete, login] } diff --git a/src/routes/asset.rs b/src/routes/asset.rs new file mode 100644 index 0000000..172baaa --- /dev/null +++ b/src/routes/asset.rs @@ -0,0 +1,133 @@ +use rocket::{ + form::Form, + fs::NamedFile, + response::Responder, + serde::json::Json, + tokio::fs::{create_dir_all, File}, + State, +}; +use surrealdb::{engine::remote::ws::Client, Surreal}; + +use crate::{ + models::{ + asset::{CreateAsset, UserContent}, + error::Error, + response::{Empty, Response}, + Credentials, + }, + utils::{asset, session}, + Result, +}; + +#[put("/upload", format = "multipart/form-data", data = "")] +pub async fn upload( + db: &State>, + data: Form>, +) -> Result { + if !session::verify(db, data.auth.id, data.auth.token).await { + return Err(Error::Unauthorized(Json( + "Failed to grant access permission".into(), + ))); + } + + let file_extension = data + .file + .content_type() + .and_then(|ext| ext.extension().map(ToString::to_string)) + .ok_or_else(|| Error::BadRequest(Json("Invalid file type".into())))?; + + let user_path = std::env::current_dir() + .unwrap() + .join("content") + .join(data.auth.id); + + if !user_path.exists() { + create_dir_all(&user_path) + .await + .map_err(|e| Error::ServerError(Json(e.to_string().into())))?; + } + let file_name = format!("{}.{}", uuid::Uuid::new_v4(), file_extension); + let file_path = user_path.join(&file_name); + + let mut file = data + .file + .open() + .await + .map_err(|e| Error::ServerError(Json(format!("Failed to open file: {}", e).into())))?; + let mut output_file = File::create(&file_path) + .await + .map_err(|e| Error::ServerError(Json(format!("Failed to create file: {}", e).into())))?; + + rocket::tokio::io::copy(&mut file, &mut output_file) + .await + .map_err(|e| Error::ServerError(Json(format!("Failed to save file: {}", e).into())))?; + + let asset = asset::create( + db, + data.owner.clone(), + data.file + .name() + .unwrap_or(&uuid::Uuid::new_v4().to_string()), + file_path, + ) + .await + .map_err(|e| Error::ServerError(Json(e.into())))? + .ok_or(Error::ServerError(Json("Failed to create asset".into())))?; + Ok(Json(Response { + success: true, + message: "Content updated successfully".into(), + data: Some(UserContent { + id: asset.id.unwrap().id.to_string(), + }), + })) +} + +pub struct AssetFile(NamedFile); + +impl<'r, 'o: 'r> Responder<'r, 'o> for AssetFile { + fn respond_to(self, req: &rocket::Request) -> rocket::response::Result<'o> { + rocket::Response::build_from(self.0.respond_to(req)?) + .raw_header("Cache-control", "max-age=86400") // 24h (24*60*60) + .ok() + } +} + +#[get("/")] +pub async fn get(db: &State>, id: &str) -> Option { + let asset = asset::get_by_id(db, id).await.ok()??; + + Some(AssetFile(NamedFile::open(&asset.path).await.ok()?)) +} + +#[post("/delete/", data = "")] +pub async fn delete( + db: &State>, + id: &str, + auth: Form>, +) -> Result { + if !session::verify(db, auth.id, auth.token).await { + return Err(Error::Unauthorized(Json( + "Failed to grant access permission".into(), + ))); + } + + let asset = asset::get_by_id(db, id) + .await + .map_err(|e| Error::from(e))? + .ok_or(Error::NotFound(Json("Asset not found".into())))?; + + rocket::tokio::fs::remove_file(&asset.path) + .await + .map_err(|e| Error::ServerError(Json(format!("Failed to delete file: {}", e).into())))?; + + Ok(Json(Response { + success: true, + message: "Content deleted successfully".into(), + data: None, + })) +} + +pub fn routes() -> Vec { + use rocket::routes; + routes![upload, get, delete] +} diff --git a/src/routes/index.rs b/src/routes/index.rs index c78c159..fc98ff0 100644 --- a/src/routes/index.rs +++ b/src/routes/index.rs @@ -1,5 +1,6 @@ use std::path::{Path, PathBuf}; +use super::asset; use super::contest; use super::organization; use super::problem; @@ -37,6 +38,7 @@ pub async fn rocket() -> rocket::Rocket { .attach(CORS) .mount("/", routes![index, files]) .mount("/account", account::routes()) + .mount("/asset", asset::routes()) .mount("/problem", problem::routes()) .mount("/org", organization::routes()) .mount("/contest", contest::routes()) diff --git a/src/utils/account.rs b/src/utils/account.rs index 9bf2f3f..2101030 100644 --- a/src/utils/account.rs +++ b/src/utils/account.rs @@ -3,11 +3,10 @@ use serde::Deserialize; use surrealdb::engine::remote::ws::Client; use surrealdb::Surreal; -use crate::models::account::{Account, Profile}; +use crate::models::account::{Account, Profile, Register}; use crate::models::UpdateAt; -use crate::routes::account::RegisterData; -pub async fn create(db: &Surreal, register: RegisterData) -> Result> { +pub async fn create(db: &Surreal, register: Register) -> Result> { let mut queried = db .query("SELECT * FROM account WHERE username = $username OR email = $email") .bind(("username", register.username.clone())) diff --git a/src/utils/asset.rs b/src/utils/asset.rs new file mode 100644 index 0000000..ce0191a --- /dev/null +++ b/src/utils/asset.rs @@ -0,0 +1,39 @@ +use std::path::PathBuf; + +use anyhow::Result; +use surrealdb::{engine::remote::ws::Client, sql::Thing, Surreal}; + +use crate::models::{asset::Asset, UserRecordId}; + +pub async fn create( + db: &Surreal, + owner: UserRecordId, + name: &str, + path: PathBuf, +) -> Result> { + Ok(db + .create("asset") + .content(Asset { + id: None, + name: name.to_string(), + owner: owner.into(), + path, + }) + .await?) +} + +pub async fn get_by_id(db: &Surreal, id: &str) -> Result> { + Ok(db.select(("asset", id)).await?) +} + +pub async fn list_by_owner(db: &Surreal, owner: UserRecordId) -> Result> { + Ok(db + .query("SELECT * FROM asset WHERE owner = $owner") + .bind(("owner", Into::::into(owner))) + .await? + .take(0)?) +} + +pub async fn delete(db: &Surreal, id: &str) -> Result> { + Ok(db.delete(("asset", id)).await?) +} diff --git a/tests/account.rs b/tests/account.rs index c445c0f..36a53e9 100644 --- a/tests/account.rs +++ b/tests/account.rs @@ -1,38 +1,50 @@ +mod utils; + use std::{fs::File, io::Read, path::Path}; use algohub_server::{ models::{ - account::Profile, + account::{Profile, Register}, + asset::UserContent, response::{Empty, Response}, - Token, + Credentials, OwnedCredentials, Token, }, - routes::account::{MergeProfile, RegisterData, RegisterResponse, UploadResponse}, + routes::account::MergeProfile, }; use anyhow::Result; use rocket::{http::ContentType, local::asynchronous::Client}; -pub struct Upload { - pub id: String, - pub token: String, - file: File, +pub struct Upload<'a> { + pub auth: Credentials<'a>, + pub owner_id: &'a str, + pub file: File, } -impl AsRef<[u8]> for Upload { + +impl<'a> AsRef<[u8]> for Upload<'a> { fn as_ref(&self) -> &[u8] { let boundary = "boundary"; let mut body = Vec::new(); body.extend( format!( - "--{boundary}\r\nContent-Disposition: form-data; name=\"id\"\r\n\r\n{}\r\n", - self.id + "--{boundary}\r\nContent-Disposition: form-data; name=\"auth[id]\"\r\n\r\n{}\r\n", + self.auth.id ) .as_bytes(), ); body.extend( format!( - "--{boundary}\r\nContent-Disposition: form-data; name=\"token\"\r\n\r\n{}\r\n", - self.token + "--{boundary}\r\nContent-Disposition: form-data; name=\"auth[token]\"\r\n\r\n{}\r\n", + self.auth.token + ) + .as_bytes(), + ); + + body.extend( + format!( + "--{boundary}\r\nContent-Disposition: form-data; name=\"owner\"\r\n\r\naccount:{}\r\n", + self.owner_id ) .as_bytes(), ); @@ -66,7 +78,7 @@ async fn test_register() -> Result<()> { println!("Testing register..."); let response = client .post("/account/create") - .json(&RegisterData { + .json(&Register { username: "fu050409".to_string(), password: "password".to_string(), email: "email@example.com".to_string(), @@ -81,13 +93,12 @@ async fn test_register() -> Result<()> { message: _, data, } = response.into_json().await.unwrap(); - let data: RegisterResponse = data.unwrap(); + let data: OwnedCredentials = data.unwrap(); let id = data.id.clone(); let token = data.token.clone(); assert!(success); - println!("Registered account: {:?}", &data); let response = client .post("/account/profile") @@ -144,11 +155,14 @@ async fn test_register() -> Result<()> { assert_eq!(data.name, Some("苏向夜".into())); let response = client - .put("/account/content/upload") + .put("/asset/upload") .header(ContentType::new("multipart", "form-data").with_params(("boundary", "boundary"))) .body(Upload { - id: id.clone(), - token: token.clone(), + auth: Credentials { + id: &id, + token: &token, + }, + owner_id: &id, file: File::open("tests/test.png")?, }) .dispatch() @@ -161,10 +175,9 @@ async fn test_register() -> Result<()> { message: _, data, } = response.into_json().await.unwrap(); - let data: UploadResponse = data.unwrap(); + let _: UserContent = data.unwrap(); assert!(success); - assert!(data.uri.starts_with("/account/content/")); let response = client .post(format!("/account/delete/{}", id)) diff --git a/tests/problem.rs b/tests/problem.rs index e6180be..4379623 100644 --- a/tests/problem.rs +++ b/tests/problem.rs @@ -1,13 +1,11 @@ use algohub_server::{ models::{ + account::Register, problem::ProblemDetail, response::{Empty, Response}, OwnedCredentials, Token, UserRecordId, }, - routes::{ - account::{RegisterData, RegisterResponse}, - problem::{CreateProblem, ListProblem, ProblemResponse}, - }, + routes::problem::{CreateProblem, ListProblem, ProblemResponse}, }; use anyhow::Result; use rocket::local::asynchronous::Client; @@ -21,7 +19,7 @@ async fn test_problem() -> Result<()> { println!("Testing register..."); let response = client .post("/account/create") - .json(&RegisterData { + .json(&Register { username: "fu050409".to_string(), password: "password".to_string(), email: "email@example.com".to_string(), @@ -36,13 +34,12 @@ async fn test_problem() -> Result<()> { message: _, data, } = response.into_json().await.unwrap(); - let data: RegisterResponse = data.unwrap(); + let data: OwnedCredentials = data.unwrap(); let id = data.id.clone(); let token = data.token.clone(); assert!(success); - println!("Registered account: {:?}", &data); for i in 0..10 { let response = client diff --git a/tests/utils.rs b/tests/utils.rs new file mode 100644 index 0000000..e69de29