diff --git a/Cargo.lock b/Cargo.lock index adc1cbb..b2b3f59 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -722,9 +722,9 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "emojis" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a08afd8e599463c275703532e707c767b8c068a826eea9ca8fceaf3435029df" +checksum = "dbf035af17e73b37a9ac6b0efda5f1f4974ee6f6080e33dda268086e84fbcbd1" dependencies = [ "phf 0.12.1", ] @@ -1257,7 +1257,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "socket2", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -1349,7 +1349,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.5.10", "system-configuration", "tokio", "tower-service", @@ -2281,7 +2281,7 @@ dependencies = [ "hmac", "md-5", "memchr", - "rand 0.9.1", + "rand 0.9.2", "sha2", "stringprep", ] @@ -2447,9 +2447,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.3", @@ -2607,7 +2607,7 @@ dependencies = [ "pin-project-lite", "ryu", "sha1_smol", - "socket2", + "socket2 0.5.10", "tokio", "tokio-util", "url", @@ -2904,9 +2904,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.141" +version = "1.0.142" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3" +checksum = "030fedb782600dcbd6f02d479bf0d817ac3bb40d644745b769d6a96bc3afc5a7" dependencies = [ "itoa", "memchr", @@ -3104,6 +3104,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "socket2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "spin" version = "0.9.8" @@ -3338,7 +3348,7 @@ dependencies = [ "tetratto-l10n", "tetratto-shared", "tokio", - "toml 0.9.2", + "toml 0.9.4", "totp-rs", ] @@ -3348,7 +3358,7 @@ version = "12.0.0" dependencies = [ "pathbufd", "serde", - "toml 0.9.2", + "toml 0.9.4", ] [[package]] @@ -3359,7 +3369,7 @@ dependencies = [ "chrono", "hex_fmt", "pulldown-cmark", - "rand 0.9.1", + "rand 0.9.2", "regex", "serde", "sha2", @@ -3486,9 +3496,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.46.1" +version = "1.47.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc3a2344dafbe23a245241fe8b09735b521110d30fcefbbd5feb1797ca35d17" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" dependencies = [ "backtrace", "bytes", @@ -3498,9 +3508,9 @@ dependencies = [ "parking_lot", "pin-project-lite", "slab", - "socket2", + "socket2 0.6.0", "tokio-macros", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3543,8 +3553,8 @@ dependencies = [ "pin-project-lite", "postgres-protocol", "postgres-types", - "rand 0.9.1", - "socket2", + "rand 0.9.2", + "socket2 0.5.10", "tokio", "tokio-util", "whoami", @@ -3599,9 +3609,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed0aee96c12fa71097902e0bb061a5e1ebd766a6636bb605ba401c45c1650eac" +checksum = "41ae868b5a0f67631c14589f7e250c1ea2c574ee5ba21c6c8dd4b1485705a5a1" dependencies = [ "indexmap", "serde", @@ -3668,7 +3678,7 @@ dependencies = [ "constant_time_eq", "hmac", "qrcodegen-image", - "rand 0.9.1", + "rand 0.9.2", "sha1", "sha2", "url", diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index e5ca15f..af0d66b 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -20,7 +20,7 @@ tower-http = { version = "0.6.6", features = [ "set-header", ] } axum = { version = "0.8.4", features = ["macros", "ws"] } -tokio = { version = "1.46.1", features = ["macros", "rt-multi-thread"] } +tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] } axum-extra = { version = "0.10.1", features = ["cookie", "multipart"] } ammonia = "4.1.1" tetratto-shared = { path = "../shared" } @@ -29,7 +29,7 @@ tetratto-l10n = { path = "../l10n" } image = "0.25.6" reqwest = { version = "0.12.22", features = ["json", "stream"] } regex = "1.11.1" -serde_json = "1.0.141" +serde_json = "1.0.142" mime_guess = "2.0.5" cf-turnstile = "0.2.0" contrasted = "0.1.3" @@ -42,7 +42,7 @@ async-stripe = { version = "0.41.0", features = [ "runtime-tokio-hyper", "connect", ] } -emojis = "0.7.0" +emojis = "0.7.1" webp = "0.3.0" nanoneo = "0.2.0" cookie = "0.18.1" diff --git a/crates/app/src/routes/api/v1/letters.rs b/crates/app/src/routes/api/v1/letters.rs new file mode 100644 index 0000000..35a1aa0 --- /dev/null +++ b/crates/app/src/routes/api/v1/letters.rs @@ -0,0 +1,178 @@ +use axum::{response::IntoResponse, Extension, Json, extract::Path}; +use tetratto_core::model::{auth::Notification, mail::Letter, oauth, ApiReturn, Error}; +use crate::{get_user_from_token, State, cookie::CookieJar}; +use super::CreateLetter; + +pub async fn list_received_request(jar: CookieJar, data: Extension) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadLetters) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let letters = match data.get_received_letters_by_user(user.id).await { + Ok(l) => l, + Err(e) => return Json(e.into()), + }; + + Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(letters), + }) +} + +pub async fn list_sent_request(jar: CookieJar, data: Extension) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadLetters) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let letters = match data.get_letters_by_user(user.id).await { + Ok(l) => l, + Err(e) => return Json(e.into()), + }; + + Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(letters), + }) +} + +pub async fn get_request( + jar: CookieJar, + data: Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadLetters) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let letter = match data.get_letter_by_id(id).await { + Ok(l) => l, + Err(e) => return Json(e.into()), + }; + + if !letter.can_read(&user) { + return Json(Error::NotAllowed.into()); + } + + Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(letter), + }) +} + +pub async fn delete_request( + jar: CookieJar, + data: Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageLetters) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.delete_letter(id, &user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: (), + }), + Err(e) => return Json(e.into()), + } +} + +pub async fn create_request( + jar: CookieJar, + data: Extension, + Json(props): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateLetters) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data + .create_letter(Letter::new( + user.id, + props.receivers, + props.subject, + props.content, + match props.replying_to.parse() { + Ok(x) => x, + Err(_) => return Json(Error::Unknown.into()), + }, + )) + .await + { + Ok(l) => { + // send notifications + for x in &l.receivers { + if let Err(e) = data + .create_notification(Notification::new( + "You've got mail!".to_string(), + format!( + "[@{}](/api/v1/auth/user/find/{}) has sent you a [letter](/mail/{}).", + user.username, user.id, l.id + ), + *x, + )) + .await + { + return Json(e.into()); + } + } + + // ... + Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(l), + }) + } + Err(e) => return Json(e.into()), + } +} + +pub async fn add_read_request( + jar: CookieJar, + data: Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadLetters) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let mut letter = match data.get_letter_by_id(id).await { + Ok(l) => l, + Err(e) => return Json(e.into()), + }; + + if !letter.can_read(&user) { + return Json(Error::NotAllowed.into()); + } + + if letter.read_by.contains(&user.id) { + return Json(Error::MiscError("Already marked as read".to_string()).into()); + } + + letter.read_by.push(user.id); + match data.update_letter_read_by(id, letter.read_by).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 8a5d95a..0d25f09 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -5,6 +5,7 @@ pub mod channels; pub mod communities; pub mod domains; pub mod journals; +pub mod letters; pub mod notes; pub mod notifications; pub mod products; @@ -706,6 +707,13 @@ pub fn routes() -> Router { post(products::update_description_request), ) .route("/products/{id}/price", post(products::update_price_request)) + // letters + .route("/letters", post(letters::create_request)) + .route("/letters/{id}", get(letters::get_request)) + .route("/letters/{id}", delete(letters::delete_request)) + .route("/letters/{id}/read", post(letters::add_read_request)) + .route("/letters/sent", get(letters::list_sent_request)) + .route("/letters/received", get(letters::list_received_request)) } pub fn lw_routes() -> Router { @@ -1208,3 +1216,11 @@ pub struct QueryAppData { pub query: AppDataSelectQuery, pub mode: AppDataSelectMode, } + +#[derive(Deserialize)] +pub struct CreateLetter { + pub receivers: Vec, + pub subject: String, + pub content: String, + pub replying_to: String, +} diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index bd7ac03..6ebd227 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -17,10 +17,10 @@ default = ["database", "types", "sdk"] [dependencies] pathbufd = "0.1.4" serde = { version = "1.0.219", features = ["derive"] } -toml = "0.9.2" +toml = "0.9.4" tetratto-shared = { version = "12.0.6", path = "../shared" } tetratto-l10n = { version = "12.0.0", path = "../l10n" } -serde_json = "1.0.141" +serde_json = "1.0.142" totp-rs = { version = "5.7.0", features = ["qr", "gen_secret"], optional = true } reqwest = { version = "0.12.22", features = ["json", "multipart"], optional = true } bitflags = { version = "2.9.1", optional = true } @@ -28,11 +28,11 @@ async-recursion = { version = "1.1.1", optional = true } md-5 = { version = "0.10.6", optional = true } base16ct = { version = "0.2.0", features = ["alloc"], optional = true } base64 = { version = "0.22.1", optional = true } -emojis = "0.7.0" +emojis = "0.7.1" regex = "1.11.1" oiseau = { version = "0.1.2", default-features = false, features = [ "postgres", "redis", ], optional = true } paste = { version = "1.0.15", optional = true } -tokio = { version = "1.46.1", features = ["macros", "rt-multi-thread"] } +tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] } diff --git a/crates/core/src/model/mail.rs b/crates/core/src/model/mail.rs index 8336821..f0771fa 100644 --- a/crates/core/src/model/mail.rs +++ b/crates/core/src/model/mail.rs @@ -1,5 +1,6 @@ use serde::{Serialize, Deserialize}; use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; +use crate::model::auth::User; /// A letter is the most basic structure of the mail system. Letters are sent /// and received by users. @@ -41,4 +42,9 @@ impl Letter { replying_to, } } + + /// Check if the given user can read the letter. + pub fn can_read(&self, user: &User) -> bool { + (user.id == self.owner) | self.receivers.contains(&user.id) + } } diff --git a/crates/core/src/model/oauth.rs b/crates/core/src/model/oauth.rs index aa0e00a..62fd0c1 100644 --- a/crates/core/src/model/oauth.rs +++ b/crates/core/src/model/oauth.rs @@ -76,6 +76,8 @@ pub enum AppScope { UserReadServices, /// Read the user's products. UserReadProducts, + /// Read the user's letters. + UserReadLetters, /// Create posts as the user. UserCreatePosts, /// Create messages as the user. @@ -102,6 +104,8 @@ pub enum AppScope { UserCreateServices, /// Create products on behalf of the user. UserCreateProducts, + /// Create letters on behalf of the user. + UserCreateLetters, /// Delete posts owned by the user. UserDeletePosts, /// Delete messages owned by the user. @@ -146,6 +150,8 @@ pub enum AppScope { UserManageProducts, /// Manage the user's channel mutes. UserManageChannelMutes, + /// Manage the user's letters. + UserManageLetters, /// Edit posts created by the user. UserEditPosts, /// Edit drafts created by the user. diff --git a/crates/l10n/Cargo.toml b/crates/l10n/Cargo.toml index 5993ffc..c66d7d4 100644 --- a/crates/l10n/Cargo.toml +++ b/crates/l10n/Cargo.toml @@ -11,4 +11,4 @@ homepage.workspace = true [dependencies] pathbufd = "0.1.4" serde = { version = "1.0.219", features = ["derive"] } -toml = "0.9.2" +toml = "0.9.4" diff --git a/crates/shared/Cargo.toml b/crates/shared/Cargo.toml index a866bd1..34311ac 100644 --- a/crates/shared/Cargo.toml +++ b/crates/shared/Cargo.toml @@ -12,7 +12,7 @@ ammonia = "4.1.1" chrono = "0.4.41" hex_fmt = "0.3.0" pulldown-cmark = "0.13.0" -rand = "0.9.1" +rand = "0.9.2" regex = "1.11.1" serde = { version = "1.0.219", features = ["derive"] } sha2 = "0.10.9"