diff --git a/.test/resources/readme.md b/.test/resources/readme.md new file mode 100644 index 0000000..12029f6 --- /dev/null +++ b/.test/resources/readme.md @@ -0,0 +1,3 @@ +# Resources directory + +This is a placeholder file. diff --git a/Cargo.lock b/Cargo.lock index 02b83a5..ce7e080 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -514,12 +514,6 @@ dependencies = [ "allocator-api2", ] -[[package]] -name = "hermit-abi" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7" - [[package]] name = "hex" version = "0.4.3" @@ -795,16 +789,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "num_cpus" -version = "1.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" -dependencies = [ - "hermit-abi", - "libc", -] - [[package]] name = "object" version = "0.32.2" @@ -832,16 +816,6 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb813b8af86854136c6922af0598d719255ecb2179515e6e7730d468f05c9cae" -[[package]] -name = "parking_lot" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" -dependencies = [ - "lock_api", - "parking_lot_core", -] - [[package]] name = "parking_lot_core" version = "0.9.9" @@ -1162,15 +1136,6 @@ dependencies = [ "lazy_static", ] -[[package]] -name = "signal-hook-registry" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" -dependencies = [ - "libc", -] - [[package]] name = "siphasher" version = "1.0.0" @@ -1215,6 +1180,7 @@ dependencies = [ "thiserror", "time", "tokio", + "tokio-util", "toml", "tower", "tracing", @@ -1351,10 +1317,7 @@ dependencies = [ "bytes", "libc", "mio", - "num_cpus", - "parking_lot", "pin-project-lite", - "signal-hook-registry", "socket2", "tokio-macros", "windows-sys", diff --git a/Cargo.toml b/Cargo.toml index 8b03997..7898b1a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,8 @@ axum = "0.7" libaccount = { version = "0.1", git = "https://github.com/subitlab-buf/libaccount.git", branch = "tags" } dmds = "0.2" dmds-tokio-fs = "0.2" -tokio = { version = "1.35", features = ["full"] } +tokio = { version = "1.35", features = ["rt", "macros", "sync", "fs"] } +tokio-util = { version = "0.7", features = ["io"] } tracing = "0.1" tracing-subscriber = "0.3" lettre = { version = "0.11", default-features = false, features = [ diff --git a/src/account.rs b/src/account.rs index 45f10f8..8f29621 100644 --- a/src/account.rs +++ b/src/account.rs @@ -54,6 +54,9 @@ pub enum Permission { ViewFullAccount, ViewSimpleAccount, + /// Upload resources. + UploadResource, + /// Maintain this system. Maintain, } @@ -61,7 +64,13 @@ pub enum Permission { impl libaccount::Permission for Permission { #[inline] fn default_set() -> std::collections::HashSet { - [Self::Post, Self::GetPubPost].into() + [ + Self::Post, + Self::GetPubPost, + Self::ViewSimpleAccount, + Self::UploadResource, + ] + .into() } #[inline] diff --git a/src/config.rs b/src/config.rs index 3f0653f..9b3c36f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8,10 +8,13 @@ pub struct Config { /// SMTP configuration. pub smtp: SMTP, - /// The root path of database. + /// The root path of the database. pub db_path: PathBuf, /// The port of HTTP server. pub port: u16, + + /// The root path of resource files. + pub resource_path: PathBuf, } /// SMTP mailing configuration. diff --git a/src/handle/account.rs b/src/handle/account.rs index 07e1e3f..dff938f 100644 --- a/src/handle/account.rs +++ b/src/handle/account.rs @@ -34,6 +34,7 @@ pub async fn send_captcha( worlds, config, test_cx, + .. }): State>, Json(SendCaptchaReq { email }): Json, ) -> Result<(), Error> { @@ -145,6 +146,7 @@ pub async fn send_reset_password_captcha( worlds, config, test_cx, + .. }): State>, Json(SendResetPasswordCaptchaReq { email }): Json, ) -> Result<(), Error> { @@ -317,7 +319,7 @@ pub async fn set_permissions( #[derive(Serialize)] #[serde(tag = "type")] -pub enum GetInfoRes { +pub enum Info { Simple { name: String, email: String, @@ -344,7 +346,7 @@ pub enum GetInfoRes { }, } -impl GetInfoRes { +impl Info { fn from_simple(account: &Account) -> Self { Self::Simple { name: account.name().to_owned(), @@ -392,7 +394,7 @@ pub async fn get_info( Path(target): Path, auth: Auth, State(Global { worlds, .. }): State>, -) -> Result, Error> { +) -> Result, Error> { let select = sd!(worlds.account, auth.account); let this_lazy = va!(auth, select => ViewSimpleAccount); let select = sd!(worlds.account, target); @@ -400,16 +402,16 @@ pub async fn get_info( let account = lazy.get().await?; if auth.account == account.id() { - Ok(Json(GetInfoRes::from_owned(account))) + Ok(Json(Info::from_owned(account))) } else if this_lazy .get() .await? .tags() .contains_permission(&Tag::Permission(Permission::ViewFullAccount)) { - Ok(Json(GetInfoRes::from_full(account))) + Ok(Json(Info::from_full(account))) } else { - Ok(Json(GetInfoRes::from_simple(account))) + Ok(Json(Info::from_simple(account))) } } @@ -424,7 +426,7 @@ pub async fn bulk_get_info( auth: Auth, State(Global { worlds, .. }): State>, Json(BulkGetInfoReq { accounts }): Json, -) -> Result>, Error> { +) -> Result>, Error> { let select = sd!(worlds.account, auth.account); va!(auth, select => ViewSimpleAccount); if let Some(last) = accounts.first().copied() { @@ -438,7 +440,7 @@ pub async fn bulk_get_info( while let Some(Ok(lazy)) = iter.next().await { if accounts.contains(&lazy.id()) { if let Ok(account) = lazy.get().await { - res.insert(account.id(), GetInfoRes::from_simple(account)); + res.insert(account.id(), Info::from_simple(account)); } } } diff --git a/src/handle/post.rs b/src/handle/post.rs index 901d3f0..bc416e5 100644 --- a/src/handle/post.rs +++ b/src/handle/post.rs @@ -15,16 +15,61 @@ use time::{Date, OffsetDateTime}; use crate::{Auth, Global}; +/// Request body for creating a new post. +/// +/// # Examples +/// +/// ```json +/// { +/// "title": "Test Post", +/// "notes": "This is a test post.", +/// "time": { +/// "start": "2021-09-01", +/// "end": "2021-09-05", +/// }, +/// "resources": [1, 2, 3], +/// "grouped": true, +/// "priority": "Normal", +/// } +/// ``` #[derive(Deserialize)] pub struct NewPostReq { + /// Title of the post. pub title: String, + /// Notes of the post. + /// + /// The notes will be stored as the notes of + /// initialize state of the post. pub notes: String, + /// Time range of the post. pub time: RangeInclusive, + /// List of resource ids this post used. pub resources: Box<[u64]>, + /// Whether this post should be played as + /// a full sequence. pub grouped: bool, + /// Priority of the post. pub priority: Priority, } +/// Creates a new post. +/// +/// # Request +/// +/// The request body is declared as [`NewPostReq`]. +/// +/// # Authorization +/// +/// The request must be authorized with [`Permission::Post`]. +/// +/// # Errors +/// +/// The request will returns an error if: +/// +/// - The given resources are not owned by the +/// creator of this post, or there is no any +/// resource in the given list. +/// - The given time range is longer than **one week**. pub async fn new_post( auth: Auth, State(Global { worlds, .. }): State>, @@ -84,27 +129,44 @@ pub async fn new_post( .map_err(|_| Error::PermissionDenied) } +/// Request URL query parameters for filtering posts. +/// +/// # Examples +/// +/// ```json +/// { +/// "limit": 16, +/// "creator": 345, +/// } +/// ``` #[derive(Deserialize)] pub struct FilterPostsParams { - /// Specify posts after this id. + /// Filter posts after this id.\ + /// The field can be omitted. #[serde(default)] pub after: Option, - /// Max posts to return. + /// Max posts to return.\ + /// The field can be omitted, + /// and the default value is **64**. #[serde(default = "FilterPostsParams::DEFAULT_LIMIT")] pub limit: usize, - /// Specify posts creator. + /// Filter posts creator.\ + /// The field can be omitted. #[serde(default)] pub creator: Option, - /// Specify posts status. + /// Filter with post status.\ + /// The field can be omitted. #[serde(default)] pub status: Option, - /// Specify posts available time. + /// Filter with post available time.\ + /// The field can be omitted. #[serde(default)] pub on: Option, - /// Specify screen id. + /// Filter with screen id.\ + /// The field can be omitted. #[serde(default)] pub screen: Option, } @@ -113,11 +175,30 @@ impl FilterPostsParams { const DEFAULT_LIMIT: fn() -> usize = || 64; } +/// Response body for filtering posts. +/// +/// # Examples +/// +/// ```json +/// { +/// "posts": [1, 2, 3], +/// } +/// ``` #[derive(Serialize)] pub struct FilterPostsRes { + /// List of post ids. pub posts: Vec, } +/// Filters posts with given filter options. +/// +/// # Request +/// +/// The request **query parameters** is declared as [`FilterPostsParams`]. +/// +/// # Authorization +/// +/// The request must be authorized. pub async fn filter_posts( Query(FilterPostsParams { after, @@ -197,21 +278,43 @@ pub async fn filter_posts( Ok(Json(FilterPostsRes { posts })) } +/// Represents information of a post. #[derive(Serialize)] #[serde(tag = "type")] pub enum Info { + /// Simple information of a post. Simple { + /// Id of the post. id: u64, + /// Title of the post. title: String, + /// Creator of this post. creator: u64, /// List of resource ids this post used. resources: Box<[u64]>, + /// Whether this post should be played as + /// a full sequence. grouped: bool, + /// Priority of the post. priority: Priority, }, + /// Full information of a post. + /// + /// This variant is only available for + /// the creator of this post, or the + /// user with [`Permission::ReviewPost`]. + /// + /// This variant can only be returned + /// by [`get_info`]. Full { + /// Id of the post. id: u64, + /// Creator of this post. creator: u64, + /// The post. + /// + /// This field is flattened + /// in the data structure. #[serde(flatten)] inner: Post, }, @@ -494,6 +597,7 @@ pub async fn remove( let mut select = worlds .resource .select(0, first) + .and(1, 1) .hints(resources.iter().copied()); for id in resources.iter().copied() { select = select.plus(0, id) @@ -579,6 +683,7 @@ pub async fn bulk_remove( let mut select = worlds .resource .select(0, first) + .and(1, 1) .hints(resources_rm.iter().copied()); for id in resources_rm.iter().copied() { select = select.plus(0, id) diff --git a/src/handle/resource.rs b/src/handle/resource.rs index 8b13789..9907467 100644 --- a/src/handle/resource.rs +++ b/src/handle/resource.rs @@ -1 +1,232 @@ +use std::collections::HashMap; +use axum::{ + body::Body, + extract::{Path, State}, + Json, +}; +use dmds::{IoHandle, StreamExt}; +use serde::{Deserialize, Serialize}; + +#[allow(unused_imports)] +use sms4_backend::account::Permission; + +use sms4_backend::resource::{Resource, Variant}; +use tokio::{fs::File, io::BufReader}; + +use crate::{Auth, Error, Global}; + +/// Request body for [`new_session`]. +/// +/// # Examples +/// +/// ```json +/// { +/// "variant": { +/// "type": "Video", +/// "duration": 60, +/// } +/// } +/// ``` +#[derive(Deserialize)] +pub struct NewSessionReq { + /// Variant of the resource to upload. + pub variant: Variant, +} + +/// Response body for [`new_session`]. +/// +/// # Examples +/// +/// ```json +/// { +/// "id": 1234567890, +/// } +/// ``` +#[derive(Serialize)] +pub struct NewSessionRes { + /// Id of the upload session.\ + /// This is **not** id of the resource. + pub id: u64, +} + +/// Creates a new upload session. +/// +/// # Request +/// +/// The request body is declared as [`NewSessionReq`]. +/// +/// # Authorization +/// +/// The request must be authorized with [`Permission::UploadResource`]. +/// +/// # Response +/// +/// The response body is declared as [`NewSessionRes`]. +pub async fn new_session( + auth: Auth, + State(Global { + worlds, + resource_sessions, + .. + }): State>, + Json(NewSessionReq { variant }): Json, +) -> Result, Error> { + let select = sd!(worlds.account, auth.account); + va!(auth, select => UploadResource); + + let resource = Resource::new(variant, auth.account); + let id = resource.id(); + resource_sessions.lock().await.insert(resource); + Ok(Json(NewSessionRes { id })) +} + +/// Response body for [`upload`]. +pub struct UploadRes { + /// Id of the resource. + pub id: u64, +} + +/// Uploads a resource within the given session. +/// +/// # Request +/// +/// The request body is the raw bytes of the resource. +/// +/// # Authorization +/// +/// The request must be authorized with [`Permission::UploadResource`]. +/// +/// # Response +/// +/// The response body is declared as [`UploadRes`]. +pub async fn upload( + Path(id): Path, + auth: Auth, + State(Global { + worlds, + resource_sessions, + config, + .. + }): State>, + payload: axum::body::Bytes, +) -> Result, Error> { + let select = sd!(worlds.account, auth.account); + va!(auth, select => UploadResource); + let resource = resource_sessions + .lock() + .await + .accept(id, &payload, auth.account)?; + let id = resource.id(); + let path = config.resource_path.join(resource.file_name()); + if let Err(err) = tokio::fs::write(path, payload).await { + tracing::error!("failed to write resource file {resource:?}: {err}"); + return Err(Error::PermissionDenied); + } + + worlds + .resource + .try_insert(resource) + .await + .map_err(|_| Error::PermissionDenied)?; + Ok(Json(UploadRes { id })) +} + +/// Gets payload of a resource. +/// +/// # Authorization +/// +/// The request must be authorized with [`Permission::GetPubPost`]. +/// +/// # Response +/// +/// The response body is the raw bytes of the resource. +/// +/// # Errors +/// +/// - [`Error::ResourceNotFound`] if the resource with the given id does not exist. +/// - [`Error::PermissionDenied`] if the resource is not blocked **and** is not owned by the authorized account. +pub async fn get_payload( + Path(id): Path, + auth: Auth, + State(Global { worlds, config, .. }): State>, +) -> Result { + let select = sd!(worlds.account, auth.account); + va!(auth, select => GetPubPost); + let select = sd!(worlds.resource, id); + let lazy = gd!(select, id).ok_or(Error::ResourceNotFound(id))?; + let resource = lazy.get().await?; + if resource.owner() != auth.account && !resource.is_blocked() { + return Err(Error::PermissionDenied); + } + + // Read the file and response it asynchronously. + let path = config.resource_path.join(resource.file_name()); + Ok(Body::from_stream(tokio_util::io::ReaderStream::new( + BufReader::new(File::open(path).await.map_err(|_| Error::Unknown)?), + ))) +} + +/// Information of a resource. +#[derive(Serialize)] +pub struct Info { + /// The resource variant. + pub variant: Variant, +} + +pub async fn get_info( + Path(id): Path, + auth: Auth, + State(Global { worlds, .. }): State>, +) -> Result, Error> { + let select = sd!(worlds.account, auth.account); + va!(auth, select => GetPubPost); + let select = sd!(worlds.resource, id).and(1, 1); + let lazy = gd!(select, id).ok_or(Error::ResourceNotFound(id))?; + let resource = lazy.get().await?; + if resource.owner() != auth.account && !resource.is_blocked() { + return Err(Error::PermissionDenied); + } + Ok(Json(Info { + variant: resource.variant().clone(), + })) +} + +/// Request body for [`bulk_get_info`]. +#[derive(Deserialize)] +pub struct BulkGetInfoReq { + /// Ids of the resources. + pub ids: Box<[u64]>, +} + +pub async fn bulk_get_info( + auth: Auth, + State(Global { worlds, .. }): State>, + Json(BulkGetInfoReq { ids }): Json, +) -> Result>, Error> { + let select = sd!(worlds.account, auth.account); + va!(auth, select => GetPubPost); + + let mut infos = HashMap::with_capacity(ids.len()); + let mut select = worlds.resource.select(1, 1).hints(ids.iter().copied()); + for &id in &*ids { + select = select.and(0, id); + } + let mut iter = select.iter(); + while let Some(Ok(lazy)) = iter.next().await { + if ids.contains(&lazy.id()) { + if let Ok(resource) = lazy.get().await { + if resource.owner() != auth.account && !resource.is_blocked() { + return Err(Error::PermissionDenied); + } + infos.insert( + resource.id(), + Info { + variant: resource.variant().clone(), + }, + ); + } + } + } + Ok(Json(infos)) +} diff --git a/src/lib.rs b/src/lib.rs index 4f2b8a5..14234ab 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -73,6 +73,10 @@ pub enum Error { #[error("resource {0} has already be used")] ResourceUsed(u64), + #[error("resource save failed")] + ResourceSaveFailed, + #[error("resource {0} not found")] + ResourceNotFound(u64), #[error("database errored")] Database(dmds::Error), @@ -87,15 +91,17 @@ impl Error { Error::VerifySessionNotFound(_) | Error::ResourceUploadSessionNotFound(_) | Error::TargetAccountNotFound - | Error::UnverifiedAccountNotFound => StatusCode::NOT_FOUND, + | Error::UnverifiedAccountNotFound + | Error::ResourceNotFound(_) => StatusCode::NOT_FOUND, Error::ReqTooFrequent(_) => StatusCode::TOO_MANY_REQUESTS, Error::EmailAddress(_) => StatusCode::BAD_REQUEST, Error::Lettre(_) | Error::Smtp(_) => StatusCode::INTERNAL_SERVER_ERROR, Error::NotLoggedIn => StatusCode::UNAUTHORIZED, Error::HeaderNonAscii(_) | Error::InvalidAuthHeader => StatusCode::BAD_REQUEST, Error::ResourceUsed(_) => StatusCode::CONFLICT, - Error::Database(_) => StatusCode::INTERNAL_SERVER_ERROR, - Error::Unknown => StatusCode::IM_A_TEAPOT, + Error::Database(_) | Error::Unknown | Error::ResourceSaveFailed => { + StatusCode::INTERNAL_SERVER_ERROR + } _ => StatusCode::FORBIDDEN, } } diff --git a/src/main.rs b/src/main.rs index 9bdc874..4d09891 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,8 @@ use std::sync::Arc; use dmds::{IoHandle, World}; use lettre::AsyncSmtpTransport; -use sms4_backend::{account::Account, config::Config, Error}; +use sms4_backend::{account::Account, config::Config, resource, Error}; +use tokio::sync::{Mutex, RwLock}; macro_rules! ipc { ($c:literal) => { @@ -38,6 +39,7 @@ mod routes { pub struct Global { pub smtp_transport: Arc>, pub worlds: Arc>, + pub resource_sessions: Arc>, pub config: Arc, pub test_cx: Arc, @@ -51,6 +53,7 @@ impl Clone for Global { worlds: self.worlds.clone(), config: self.config.clone(), test_cx: self.test_cx.clone(), + resource_sessions: self.resource_sessions.clone(), } } } @@ -82,7 +85,7 @@ impl Auth { Self { account, token } } - const KEY: &str = "Authorization"; + const KEY: &'static str = "Authorization"; #[cfg(test)] pub fn append_to_req_builder(&self, builder: &mut Option) { diff --git a/src/post.rs b/src/post.rs index cee63f7..59fcec8 100644 --- a/src/post.rs +++ b/src/post.rs @@ -268,11 +268,14 @@ impl State { } } -/// Status of a post. +/// Status of a [`Post`]. #[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)] pub enum Status { + /// Pending for review. Pending, + /// Approved. Approved, + /// Rejected. Rejected, } diff --git a/src/resource.rs b/src/resource.rs index b18fad5..2f3d2d4 100644 --- a/src/resource.rs +++ b/src/resource.rs @@ -47,11 +47,23 @@ impl Resource { } } + /// Id of this resource. + #[inline] + pub fn id(&self) -> u64 { + self.id + } + #[inline] pub fn owner(&self) -> u64 { self.owner } + /// Variant of this resource. + #[inline] + pub fn variant(&self) -> Variant { + self.variant + } + /// Marks this resource as used. #[inline] pub fn block(&mut self) -> Result<(), Error> { @@ -74,6 +86,13 @@ impl Resource { pub fn is_blocked(&self) -> bool { self.used } + + const FILE_PREFIX: &'static str = "r_"; + + /// File name of this resource. + pub fn file_name(&self) -> String { + format!("{}{}", Self::FILE_PREFIX, self.id) + } } impl dmds::Data for Resource { @@ -177,8 +196,8 @@ impl UploadSessions { /// Accepts the body of a resource with given id, /// and returns the resource. /// - /// *Id of the resource* will be changed, so you have to - /// tell the new id to *the frontend*. + /// **Id of the resource will be changed**, so you have to + /// tell the new id to the frontend. pub fn accept(&mut self, id: u64, data: &[u8], user: u64) -> Result { self.cleanup(); let res = &self @@ -197,10 +216,17 @@ impl UploadSessions { } } -/// Type of a resource. +/// Type of a [`Resource`]. #[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(tag = "type")] pub enum Variant { Image, - Pdf, - Video, + Pdf { + /// Number of pages. + pages: u16, + }, + Video { + /// Video duration, as seconds. + duration: u32, + }, } diff --git a/src/tests/mod.rs b/src/tests/mod.rs index 4f5bcba..8084183 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -1,8 +1,9 @@ -use std::sync::Arc; +use std::{path::PathBuf, sync::Arc}; use axum::Router; use dmds::{mem_io_handle::MemStorage, world}; use sms4_backend::config::Config; +use tokio::sync::{Mutex, RwLock}; use crate::Global; @@ -25,6 +26,7 @@ fn router() -> (Global, Router) { }, db_path: Default::default(), port: 8080, + resource_path: PathBuf::from(".test/resources"), }; let state = Global { smtp_transport: Arc::new(config.smtp.to_transport().unwrap()), @@ -46,6 +48,7 @@ fn router() -> (Global, Router) { }), config: Arc::new(config), test_cx: Default::default(), + resource_sessions: Arc::new(Mutex::new(sms4_backend::resource::UploadSessions::new())), }; let router: Router<()> = Router::new()