diff --git a/Cargo.lock b/Cargo.lock index 4d51907..2fb0319 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -931,6 +931,12 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + [[package]] name = "futures-macro" version = "0.3.31" @@ -961,9 +967,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-core", + "futures-io", "futures-macro", "futures-sink", "futures-task", + "memchr", "pin-project-lite", "pin-utils", "slab", @@ -2502,11 +2510,13 @@ dependencies = [ "system-configuration", "tokio", "tokio-native-tls", + "tokio-util", "tower", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", "windows-registry", ] @@ -3036,6 +3046,7 @@ dependencies = [ "axum", "axum-extra", "image", + "mime_guess", "pathbufd", "regex", "reqwest", @@ -3754,6 +3765,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "web-sys" version = "0.3.77" diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 186c44a..430c007 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -19,10 +19,13 @@ axum = { version = "0.8.1", features = ["macros"] } tokio = { version = "1.44.1", features = ["macros", "rt-multi-thread"] } axum-extra = { version = "0.10.0", features = ["cookie", "multipart"] } tetratto-shared = { path = "../shared" } -tetratto-core = { path = "../core", features = ["redis"], default-features = false } +tetratto-core = { path = "../core", features = [ + "redis", +], default-features = false } tetratto-l10n = { path = "../l10n" } image = "0.25.5" -reqwest = "0.12.15" +reqwest = { version = "0.12.15", features = ["json", "stream"] } regex = "1.11.1" serde_json = "1.0.140" +mime_guess = "2.0.5" diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index aa351da..1793bc2 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -7,6 +7,7 @@ use assets::{init_dirs, write_assets}; pub use tetratto_core::*; use axum::{Extension, Router}; +use reqwest::Client; use tera::{Tera, Value}; use tower_http::trace::{self, TraceLayer}; use tracing::{Level, info}; @@ -14,7 +15,7 @@ use tracing::{Level, info}; use std::{collections::HashMap, sync::Arc}; use tokio::sync::RwLock; -pub(crate) type State = Arc>; +pub(crate) type State = Arc>; fn render_markdown(value: &Value, _: &HashMap) -> tera::Result { Ok(tetratto_shared::markdown::render_markdown(value.as_str().unwrap()).into()) @@ -40,9 +41,11 @@ async fn main() { let mut tera = Tera::new(&format!("{html_path}/**/*")).unwrap(); tera.register_filter("markdown", render_markdown); + let client = Client::new(); + let app = Router::new() .merge(routes::routes(&config)) - .layer(Extension(Arc::new(RwLock::new((database, tera))))) + .layer(Extension(Arc::new(RwLock::new((database, tera, client))))) .layer( TraceLayer::new_for_http() .make_span_with(trace::DefaultMakeSpan::new().level(Level::INFO)) diff --git a/crates/app/src/routes/api/v1/communities/communities.rs b/crates/app/src/routes/api/v1/communities/communities.rs index d60785d..9c4946e 100644 --- a/crates/app/src/routes/api/v1/communities/communities.rs +++ b/crates/app/src/routes/api/v1/communities/communities.rs @@ -293,7 +293,7 @@ pub async fn update_membership_role( match data.update_membership_role(membership.id, req.role).await { Ok(_) => { // check if the user was just banned/unbanned (and send notifs) - if (req.role & CommunityPermission::BANNED) == CommunityPermission::BANNED { + if req.role.check_banned() { // user was banned if let Err(e) = data .create_notification(Notification::new( @@ -313,8 +313,7 @@ pub async fn update_membership_role( // banned members do not count towards member count return Json(e.into()); } - } else if (membership.role & CommunityPermission::BANNED) == CommunityPermission::BANNED - { + } else if membership.role.check_banned() { // user was unbanned if let Err(e) = data .create_notification(Notification::new( diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 5da7d4f..c5da621 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -2,6 +2,7 @@ pub mod auth; pub mod communities; pub mod notifications; pub mod reactions; +pub mod util; use axum::{ Router, @@ -16,6 +17,9 @@ use tetratto_core::model::{ pub fn routes() -> Router { Router::new() + // misc + .route("/util/proxy", get(util::proxy_request)) + .route("/util/lang", get(util::set_langfile_request)) // reactions .route("/reactions", post(reactions::create_request)) .route("/reactions/{id}", get(reactions::get_request)) diff --git a/crates/app/src/routes/api/v1/notifications.rs b/crates/app/src/routes/api/v1/notifications.rs index 7561080..ed899ab 100644 --- a/crates/app/src/routes/api/v1/notifications.rs +++ b/crates/app/src/routes/api/v1/notifications.rs @@ -40,7 +40,7 @@ pub async fn delete_all_request( match data.delete_all_notifications(&user).await { Ok(_) => Json(ApiReturn { ok: true, - message: "Notifications deleted".to_string(), + message: "Notifications cleared".to_string(), payload: (), }), Err(e) => Json(e.into()), diff --git a/crates/app/src/routes/api/v1/util.rs b/crates/app/src/routes/api/v1/util.rs new file mode 100644 index 0000000..30d9dbb --- /dev/null +++ b/crates/app/src/routes/api/v1/util.rs @@ -0,0 +1,133 @@ +use super::auth::images::read_image; +use crate::State; +use axum::{Extension, body::Body, extract::Query, http::HeaderMap, response::IntoResponse}; +use pathbufd::PathBufD; +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct ProxyQuery { + pub url: String, +} + +/// Proxy an external url +pub async fn proxy_request( + Query(props): Query, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await); + let http = &data.2; + let data = &data.0; + + let image_url = &props.url; + + for host in &data.0.banned_hosts { + if image_url.starts_with(host) { + return ( + [("Content-Type", "image/svg+xml")], + Body::from(read_image(PathBufD::current().extend(&[ + data.0.dirs.media.as_str(), + "images", + "default-banner.svg", + ]))), + ); + } + } + + // get proxied image + if image_url.is_empty() { + return ( + [("Content-Type", "image/svg+xml")], + Body::from(read_image(PathBufD::current().extend(&[ + data.0.dirs.media.as_str(), + "images", + "default-banner.svg", + ]))), + ); + } + + let guessed_mime = mime_guess::from_path(image_url) + .first_raw() + .unwrap_or("application/octet-stream"); + + match http.get(image_url).send().await { + Ok(stream) => { + let size = stream.content_length(); + if size.unwrap_or_default() > 10485760 { + // return defualt image (content too big) + return ( + [("Content-Type", "image/svg+xml")], + Body::from(read_image(PathBufD::current().extend(&[ + data.0.dirs.media.as_str(), + "images", + "default-banner.svg", + ]))), + ); + } + + if let Some(ct) = stream.headers().get("Content-Type") { + let ct = ct.to_str().unwrap(); + let bad_ct = vec!["text/html", "text/plain"]; + if (!ct.starts_with("image/") && !ct.starts_with("font/")) | bad_ct.contains(&ct) { + // if we got html, return default banner (likely an error page) + return ( + [("Content-Type", "image/svg+xml")], + Body::from(read_image(PathBufD::current().extend(&[ + data.0.dirs.media.as_str(), + "images", + "default-banner.svg", + ]))), + ); + } + } + + ( + [( + "Content-Type", + if guessed_mime == "text/html" { + "text/plain" + } else { + guessed_mime + }, + )], + Body::from_stream(stream.bytes_stream()), + ) + } + Err(_) => ( + [("Content-Type", "image/svg+xml")], + Body::from(read_image(PathBufD::current().extend(&[ + data.0.dirs.media.as_str(), + "images", + "default-banner.svg", + ]))), + ), + } +} + +#[derive(Deserialize)] +pub struct LangFileQuery { + #[serde(default)] + pub id: String, +} + +/// Set the current language +pub async fn set_langfile_request(Query(props): Query) -> impl IntoResponse { + ( + { + let mut headers = HeaderMap::new(); + + headers.insert( + "Set-Cookie", + format!( + "__Secure-atto-lang={}; SameSite=Lax; Secure; Path=/; HostOnly=true; HttpOnly=true; Max-Age={}", + props.id, + 60* 60 * 24 * 365 + ) + .parse() + .unwrap(), + ); + + headers + }, + "Language changed", + ) +} diff --git a/crates/app/src/routes/pages/communities.rs b/crates/app/src/routes/pages/communities.rs index 883d97c..82e972d 100644 --- a/crates/app/src/routes/pages/communities.rs +++ b/crates/app/src/routes/pages/communities.rs @@ -31,6 +31,20 @@ macro_rules! check_permissions { } _ => (), }; + + if let Some(ref ua) = $user { + if let Ok(membership) = $data + .0 + .get_membership_by_owner_community(ua.id, $community.id) + .await + { + if membership.role.check_banned() { + return Err(Html( + render_error(Error::NotAllowed, &$jar, &$data, &$user).await, + )); + } + } + } }; } diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index b3b1343..06af1cc 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -41,7 +41,7 @@ pub fn routes() -> Router { pub async fn render_error( e: Error, jar: &CookieJar, - data: &(DataManager, tera::Tera), + data: &(DataManager, tera::Tera, reqwest::Client), user: &Option, ) -> String { let lang = get_lang!(jar, data.0); diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 87ec71e..f49d6f2 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -124,6 +124,14 @@ pub struct Config { /// The port to serve the server on. #[serde(default = "default_port")] pub port: u16, + /// A list of hosts which cannot be proxied through the image proxy. + /// + /// They will return the default banner image instead of proxying. + /// + /// It is recommended to put the host of your own public server in this list in + /// order to prevent a way too easy DOS. + #[serde(default = "default_banned_hosts")] + pub banned_hosts: Vec, /// Database security. #[serde(default = "default_security")] pub security: SecurityConfig, @@ -157,6 +165,10 @@ fn default_port() -> u16 { 4118 } +fn default_banned_hosts() -> Vec { + Vec::new() +} + fn default_security() -> SecurityConfig { SecurityConfig::default() } @@ -193,6 +205,7 @@ impl Default for Config { description: default_description(), color: default_color(), port: default_port(), + banned_hosts: default_banned_hosts(), database: default_database(), security: default_security(), dirs: default_dirs(), diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index 1b9d9aa..ce6cb01 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -1,5 +1,6 @@ use super::*; use crate::cache::Cache; +use crate::model::auth::Notification; use crate::model::{ Error, Result, auth::User, @@ -284,7 +285,7 @@ impl DataManager { /// /// # Arguments /// * `data` - a mock [`JournalEntry`] object to insert - pub async fn create_post(&self, data: Post) -> Result { + pub async fn create_post(&self, mut data: Post) -> Result { // check values if data.content.len() < 2 { return Err(Error::DataTooShort("content".to_string())); @@ -312,30 +313,50 @@ impl DataManager { } } + // send mention notifications + for username in User::parse_mentions(&data.content) { + let user = self.get_user_by_username(&username).await?; + self.create_notification(Notification::new( + "You've been mentioned in a post!".to_string(), + format!( + "[Somebody](/api/v1/auth/profile/find/{}) mentioned you in their [post](/post/{}).", + data.owner, data.id + ), + user.id, + )) + .await?; + data.content = data.content.replace( + &format!("@{username}"), + &format!("[@{username}](/api/v1/auth/profile/find/{})", user.id), + ); + } + // ... let conn = match self.connect().await { Ok(c) => c, Err(e) => return Err(Error::DatabaseConnection(e.to_string())), }; + let replying_to_id = data.replying_to.unwrap_or(0).to_string(); + let res = execute!( &conn, "INSERT INTO posts VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", &[ - &Some(data.id.to_string()), - &Some(data.created.to_string()), - &Some(data.content), - &Some(data.owner.to_string()), - &Some(data.community.to_string()), - &Some(serde_json::to_string(&data.context).unwrap()), - &if let Some(id) = data.replying_to { - Some(id.to_string()) + &Some(data.id.to_string().as_str()), + &Some(data.created.to_string().as_str()), + &Some(&data.content), + &Some(data.owner.to_string().as_str()), + &Some(data.community.to_string().as_str()), + &Some(&serde_json::to_string(&data.context).unwrap()), + &if replying_to_id != "0" { + Some(replying_to_id.as_str()) } else { None }, - &Some(0.to_string()), - &Some(0.to_string()), - &Some(0.to_string()) + &Some(0.to_string().as_str()), + &Some(0.to_string().as_str()), + &Some(0.to_string().as_str()) ] ); @@ -346,6 +367,22 @@ impl DataManager { // incr comment count if let Some(id) = data.replying_to { self.incr_post_comments(id).await.unwrap(); + + // send notification + let rt = self.get_post_by_id(id).await?; + + if data.owner != rt.owner { + let owner = self.get_user_by_id(rt.owner).await?; + self.create_notification(Notification::new( + "Your post has received a new comment!".to_string(), + format!( + "[@{}](/api/v1/auth/profile/find/{}) has commented on your [post](/post/{}).", + owner.username, owner.id, rt.id + ), + rt.owner, + )) + .await?; + } } // return diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index caadf19..f7ed81e 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -105,6 +105,49 @@ impl User { pub fn check_password(&self, against: String) -> bool { self.password == hash_salted(against, self.salt.clone()) } + + /// Parse user mentions in a given `input`. + pub fn parse_mentions(input: &str) -> Vec { + // state + let mut escape: bool = false; + let mut at: bool = false; + let mut buffer: String = String::new(); + let mut out = Vec::new(); + + // parse + for char in input.chars() { + if (char == '\\') && !escape { + escape = true; + } + + if (char == '@') && !escape { + at = true; + continue; // don't push @ + } + + if at { + if (char == ' ') && !escape { + // reached space, end @ + at = false; + + if !out.contains(&buffer) { + out.push(buffer); + } + + buffer = String::new(); + continue; + } + + // push mention text + buffer.push(char); + } + + escape = false; + } + + // return + out + } } #[derive(Debug, Serialize, Deserialize)] diff --git a/crates/core/src/model/communities_permissions.rs b/crates/core/src/model/communities_permissions.rs index 76142ad..f5fa70a 100644 --- a/crates/core/src/model/communities_permissions.rs +++ b/crates/core/src/model/communities_permissions.rs @@ -28,8 +28,8 @@ impl Serialize for CommunityPermission { } } -struct JournalPermissionVisitor; -impl<'de> Visitor<'de> for JournalPermissionVisitor { +struct CommunityPermissionVisitor; +impl<'de> Visitor<'de> for CommunityPermissionVisitor { type Value = CommunityPermission; fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { @@ -75,22 +75,22 @@ impl<'de> Deserialize<'de> for CommunityPermission { where D: Deserializer<'de>, { - deserializer.deserialize_any(JournalPermissionVisitor) + deserializer.deserialize_any(CommunityPermissionVisitor) } } impl CommunityPermission { - /// Join two [`JournalPermission`]s into a single `u32`. + /// Join two [`CommunityPermission`]s into a single `u32`. pub fn join(lhs: CommunityPermission, rhs: CommunityPermission) -> CommunityPermission { lhs | rhs } - /// Check if the given `input` contains the given [`JournalPermission`]. + /// Check if the given `input` contains the given [`CommunityPermission`]. pub fn check(self, permission: CommunityPermission) -> bool { if (self & CommunityPermission::ADMINISTRATOR) == CommunityPermission::ADMINISTRATOR { // has administrator permission, meaning everything else is automatically true return true; - } else if (self & CommunityPermission::BANNED) == CommunityPermission::BANNED { + } else if self.check_banned() { // has banned permission, meaning everything else is automatically false return false; } @@ -98,15 +98,20 @@ impl CommunityPermission { (self & permission) == permission } - /// Check if the given [`JournalPermission`] qualifies as "Member" status. + /// Check if the given [`CommunityPermission`] qualifies as "Member" status. pub fn check_member(self) -> bool { self.check(CommunityPermission::MEMBER) } - /// Check if the given [`JournalPermission`] qualifies as "Moderator" status. + /// Check if the given [`CommunityPermission`] qualifies as "Moderator" status. pub fn check_moderator(self) -> bool { self.check(CommunityPermission::MANAGE_POSTS) } + + /// Check if the given [`CommunityPermission`] qualifies as "Banned" status. + pub fn check_banned(self) -> bool { + (self & CommunityPermission::BANNED) == CommunityPermission::BANNED + } } impl Default for CommunityPermission { diff --git a/crates/shared/src/markdown.rs b/crates/shared/src/markdown.rs index 5fc5994..5030831 100644 --- a/crates/shared/src/markdown.rs +++ b/crates/shared/src/markdown.rs @@ -34,10 +34,7 @@ pub fn render_markdown(input: &str) -> String { .generic_attributes(allowed_attributes) .clean(&html) .to_string() - .replace( - "src=\"", - "loading=\"lazy\" src=\"/api/v1/util/ext/image?img=", - ) + .replace("src=\"", "loading=\"lazy\" src=\"/api/v1/util/proxy?url=") .replace("-->", "") .replace("->", "") .replace("<-", "")