add: image proxy
add: mentions in posts TODO: audit log, reports, user mod panel
This commit is contained in:
parent
e183a01887
commit
3a8af17154
14 changed files with 308 additions and 33 deletions
24
Cargo.lock
generated
24
Cargo.lock
generated
|
@ -931,6 +931,12 @@ version = "0.3.31"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
|
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-io"
|
||||||
|
version = "0.3.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-macro"
|
name = "futures-macro"
|
||||||
version = "0.3.31"
|
version = "0.3.31"
|
||||||
|
@ -961,9 +967,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
|
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-io",
|
||||||
"futures-macro",
|
"futures-macro",
|
||||||
"futures-sink",
|
"futures-sink",
|
||||||
"futures-task",
|
"futures-task",
|
||||||
|
"memchr",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"pin-utils",
|
"pin-utils",
|
||||||
"slab",
|
"slab",
|
||||||
|
@ -2502,11 +2510,13 @@ dependencies = [
|
||||||
"system-configuration",
|
"system-configuration",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-native-tls",
|
"tokio-native-tls",
|
||||||
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"url",
|
"url",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
|
"wasm-streams",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
"windows-registry",
|
"windows-registry",
|
||||||
]
|
]
|
||||||
|
@ -3036,6 +3046,7 @@ dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"axum-extra",
|
"axum-extra",
|
||||||
"image",
|
"image",
|
||||||
|
"mime_guess",
|
||||||
"pathbufd",
|
"pathbufd",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
@ -3754,6 +3765,19 @@ dependencies = [
|
||||||
"unicode-ident",
|
"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]]
|
[[package]]
|
||||||
name = "web-sys"
|
name = "web-sys"
|
||||||
version = "0.3.77"
|
version = "0.3.77"
|
||||||
|
|
|
@ -19,10 +19,13 @@ axum = { version = "0.8.1", features = ["macros"] }
|
||||||
tokio = { version = "1.44.1", features = ["macros", "rt-multi-thread"] }
|
tokio = { version = "1.44.1", features = ["macros", "rt-multi-thread"] }
|
||||||
axum-extra = { version = "0.10.0", features = ["cookie", "multipart"] }
|
axum-extra = { version = "0.10.0", features = ["cookie", "multipart"] }
|
||||||
tetratto-shared = { path = "../shared" }
|
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" }
|
tetratto-l10n = { path = "../l10n" }
|
||||||
|
|
||||||
image = "0.25.5"
|
image = "0.25.5"
|
||||||
reqwest = "0.12.15"
|
reqwest = { version = "0.12.15", features = ["json", "stream"] }
|
||||||
regex = "1.11.1"
|
regex = "1.11.1"
|
||||||
serde_json = "1.0.140"
|
serde_json = "1.0.140"
|
||||||
|
mime_guess = "2.0.5"
|
||||||
|
|
|
@ -7,6 +7,7 @@ use assets::{init_dirs, write_assets};
|
||||||
pub use tetratto_core::*;
|
pub use tetratto_core::*;
|
||||||
|
|
||||||
use axum::{Extension, Router};
|
use axum::{Extension, Router};
|
||||||
|
use reqwest::Client;
|
||||||
use tera::{Tera, Value};
|
use tera::{Tera, Value};
|
||||||
use tower_http::trace::{self, TraceLayer};
|
use tower_http::trace::{self, TraceLayer};
|
||||||
use tracing::{Level, info};
|
use tracing::{Level, info};
|
||||||
|
@ -14,7 +15,7 @@ use tracing::{Level, info};
|
||||||
use std::{collections::HashMap, sync::Arc};
|
use std::{collections::HashMap, sync::Arc};
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
pub(crate) type State = Arc<RwLock<(DataManager, Tera)>>;
|
pub(crate) type State = Arc<RwLock<(DataManager, Tera, Client)>>;
|
||||||
|
|
||||||
fn render_markdown(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
|
fn render_markdown(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
|
||||||
Ok(tetratto_shared::markdown::render_markdown(value.as_str().unwrap()).into())
|
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();
|
let mut tera = Tera::new(&format!("{html_path}/**/*")).unwrap();
|
||||||
tera.register_filter("markdown", render_markdown);
|
tera.register_filter("markdown", render_markdown);
|
||||||
|
|
||||||
|
let client = Client::new();
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.merge(routes::routes(&config))
|
.merge(routes::routes(&config))
|
||||||
.layer(Extension(Arc::new(RwLock::new((database, tera)))))
|
.layer(Extension(Arc::new(RwLock::new((database, tera, client)))))
|
||||||
.layer(
|
.layer(
|
||||||
TraceLayer::new_for_http()
|
TraceLayer::new_for_http()
|
||||||
.make_span_with(trace::DefaultMakeSpan::new().level(Level::INFO))
|
.make_span_with(trace::DefaultMakeSpan::new().level(Level::INFO))
|
||||||
|
|
|
@ -293,7 +293,7 @@ pub async fn update_membership_role(
|
||||||
match data.update_membership_role(membership.id, req.role).await {
|
match data.update_membership_role(membership.id, req.role).await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
// check if the user was just banned/unbanned (and send notifs)
|
// 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
|
// user was banned
|
||||||
if let Err(e) = data
|
if let Err(e) = data
|
||||||
.create_notification(Notification::new(
|
.create_notification(Notification::new(
|
||||||
|
@ -313,8 +313,7 @@ pub async fn update_membership_role(
|
||||||
// banned members do not count towards member count
|
// banned members do not count towards member count
|
||||||
return Json(e.into());
|
return Json(e.into());
|
||||||
}
|
}
|
||||||
} else if (membership.role & CommunityPermission::BANNED) == CommunityPermission::BANNED
|
} else if membership.role.check_banned() {
|
||||||
{
|
|
||||||
// user was unbanned
|
// user was unbanned
|
||||||
if let Err(e) = data
|
if let Err(e) = data
|
||||||
.create_notification(Notification::new(
|
.create_notification(Notification::new(
|
||||||
|
|
|
@ -2,6 +2,7 @@ pub mod auth;
|
||||||
pub mod communities;
|
pub mod communities;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
pub mod reactions;
|
pub mod reactions;
|
||||||
|
pub mod util;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Router,
|
Router,
|
||||||
|
@ -16,6 +17,9 @@ use tetratto_core::model::{
|
||||||
|
|
||||||
pub fn routes() -> Router {
|
pub fn routes() -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
|
// misc
|
||||||
|
.route("/util/proxy", get(util::proxy_request))
|
||||||
|
.route("/util/lang", get(util::set_langfile_request))
|
||||||
// reactions
|
// reactions
|
||||||
.route("/reactions", post(reactions::create_request))
|
.route("/reactions", post(reactions::create_request))
|
||||||
.route("/reactions/{id}", get(reactions::get_request))
|
.route("/reactions/{id}", get(reactions::get_request))
|
||||||
|
|
|
@ -40,7 +40,7 @@ pub async fn delete_all_request(
|
||||||
match data.delete_all_notifications(&user).await {
|
match data.delete_all_notifications(&user).await {
|
||||||
Ok(_) => Json(ApiReturn {
|
Ok(_) => Json(ApiReturn {
|
||||||
ok: true,
|
ok: true,
|
||||||
message: "Notifications deleted".to_string(),
|
message: "Notifications cleared".to_string(),
|
||||||
payload: (),
|
payload: (),
|
||||||
}),
|
}),
|
||||||
Err(e) => Json(e.into()),
|
Err(e) => Json(e.into()),
|
||||||
|
|
133
crates/app/src/routes/api/v1/util.rs
Normal file
133
crates/app/src/routes/api/v1/util.rs
Normal file
|
@ -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<ProxyQuery>,
|
||||||
|
Extension(data): Extension<State>,
|
||||||
|
) -> 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<LangFileQuery>) -> 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",
|
||||||
|
)
|
||||||
|
}
|
|
@ -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,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -41,7 +41,7 @@ pub fn routes() -> Router {
|
||||||
pub async fn render_error(
|
pub async fn render_error(
|
||||||
e: Error,
|
e: Error,
|
||||||
jar: &CookieJar,
|
jar: &CookieJar,
|
||||||
data: &(DataManager, tera::Tera),
|
data: &(DataManager, tera::Tera, reqwest::Client),
|
||||||
user: &Option<User>,
|
user: &Option<User>,
|
||||||
) -> String {
|
) -> String {
|
||||||
let lang = get_lang!(jar, data.0);
|
let lang = get_lang!(jar, data.0);
|
||||||
|
|
|
@ -124,6 +124,14 @@ pub struct Config {
|
||||||
/// The port to serve the server on.
|
/// The port to serve the server on.
|
||||||
#[serde(default = "default_port")]
|
#[serde(default = "default_port")]
|
||||||
pub port: u16,
|
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<String>,
|
||||||
/// Database security.
|
/// Database security.
|
||||||
#[serde(default = "default_security")]
|
#[serde(default = "default_security")]
|
||||||
pub security: SecurityConfig,
|
pub security: SecurityConfig,
|
||||||
|
@ -157,6 +165,10 @@ fn default_port() -> u16 {
|
||||||
4118
|
4118
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_banned_hosts() -> Vec<String> {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
fn default_security() -> SecurityConfig {
|
fn default_security() -> SecurityConfig {
|
||||||
SecurityConfig::default()
|
SecurityConfig::default()
|
||||||
}
|
}
|
||||||
|
@ -193,6 +205,7 @@ impl Default for Config {
|
||||||
description: default_description(),
|
description: default_description(),
|
||||||
color: default_color(),
|
color: default_color(),
|
||||||
port: default_port(),
|
port: default_port(),
|
||||||
|
banned_hosts: default_banned_hosts(),
|
||||||
database: default_database(),
|
database: default_database(),
|
||||||
security: default_security(),
|
security: default_security(),
|
||||||
dirs: default_dirs(),
|
dirs: default_dirs(),
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::cache::Cache;
|
use crate::cache::Cache;
|
||||||
|
use crate::model::auth::Notification;
|
||||||
use crate::model::{
|
use crate::model::{
|
||||||
Error, Result,
|
Error, Result,
|
||||||
auth::User,
|
auth::User,
|
||||||
|
@ -284,7 +285,7 @@ impl DataManager {
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
/// * `data` - a mock [`JournalEntry`] object to insert
|
/// * `data` - a mock [`JournalEntry`] object to insert
|
||||||
pub async fn create_post(&self, data: Post) -> Result<usize> {
|
pub async fn create_post(&self, mut data: Post) -> Result<usize> {
|
||||||
// check values
|
// check values
|
||||||
if data.content.len() < 2 {
|
if data.content.len() < 2 {
|
||||||
return Err(Error::DataTooShort("content".to_string()));
|
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 {
|
let conn = match self.connect().await {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let replying_to_id = data.replying_to.unwrap_or(0).to_string();
|
||||||
|
|
||||||
let res = execute!(
|
let res = execute!(
|
||||||
&conn,
|
&conn,
|
||||||
"INSERT INTO posts VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
|
"INSERT INTO posts VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
|
||||||
&[
|
&[
|
||||||
&Some(data.id.to_string()),
|
&Some(data.id.to_string().as_str()),
|
||||||
&Some(data.created.to_string()),
|
&Some(data.created.to_string().as_str()),
|
||||||
&Some(data.content),
|
&Some(&data.content),
|
||||||
&Some(data.owner.to_string()),
|
&Some(data.owner.to_string().as_str()),
|
||||||
&Some(data.community.to_string()),
|
&Some(data.community.to_string().as_str()),
|
||||||
&Some(serde_json::to_string(&data.context).unwrap()),
|
&Some(&serde_json::to_string(&data.context).unwrap()),
|
||||||
&if let Some(id) = data.replying_to {
|
&if replying_to_id != "0" {
|
||||||
Some(id.to_string())
|
Some(replying_to_id.as_str())
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
},
|
},
|
||||||
&Some(0.to_string()),
|
&Some(0.to_string().as_str()),
|
||||||
&Some(0.to_string()),
|
&Some(0.to_string().as_str()),
|
||||||
&Some(0.to_string())
|
&Some(0.to_string().as_str())
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -346,6 +367,22 @@ impl DataManager {
|
||||||
// incr comment count
|
// incr comment count
|
||||||
if let Some(id) = data.replying_to {
|
if let Some(id) = data.replying_to {
|
||||||
self.incr_post_comments(id).await.unwrap();
|
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
|
// return
|
||||||
|
|
|
@ -105,6 +105,49 @@ impl User {
|
||||||
pub fn check_password(&self, against: String) -> bool {
|
pub fn check_password(&self, against: String) -> bool {
|
||||||
self.password == hash_salted(against, self.salt.clone())
|
self.password == hash_salted(against, self.salt.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse user mentions in a given `input`.
|
||||||
|
pub fn parse_mentions(input: &str) -> Vec<String> {
|
||||||
|
// 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)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
|
|
@ -28,8 +28,8 @@ impl Serialize for CommunityPermission {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct JournalPermissionVisitor;
|
struct CommunityPermissionVisitor;
|
||||||
impl<'de> Visitor<'de> for JournalPermissionVisitor {
|
impl<'de> Visitor<'de> for CommunityPermissionVisitor {
|
||||||
type Value = CommunityPermission;
|
type Value = CommunityPermission;
|
||||||
|
|
||||||
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||||
|
@ -75,22 +75,22 @@ impl<'de> Deserialize<'de> for CommunityPermission {
|
||||||
where
|
where
|
||||||
D: Deserializer<'de>,
|
D: Deserializer<'de>,
|
||||||
{
|
{
|
||||||
deserializer.deserialize_any(JournalPermissionVisitor)
|
deserializer.deserialize_any(CommunityPermissionVisitor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommunityPermission {
|
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 {
|
pub fn join(lhs: CommunityPermission, rhs: CommunityPermission) -> CommunityPermission {
|
||||||
lhs | rhs
|
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 {
|
pub fn check(self, permission: CommunityPermission) -> bool {
|
||||||
if (self & CommunityPermission::ADMINISTRATOR) == CommunityPermission::ADMINISTRATOR {
|
if (self & CommunityPermission::ADMINISTRATOR) == CommunityPermission::ADMINISTRATOR {
|
||||||
// has administrator permission, meaning everything else is automatically true
|
// has administrator permission, meaning everything else is automatically true
|
||||||
return true;
|
return true;
|
||||||
} else if (self & CommunityPermission::BANNED) == CommunityPermission::BANNED {
|
} else if self.check_banned() {
|
||||||
// has banned permission, meaning everything else is automatically false
|
// has banned permission, meaning everything else is automatically false
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
@ -98,15 +98,20 @@ impl CommunityPermission {
|
||||||
(self & permission) == permission
|
(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 {
|
pub fn check_member(self) -> bool {
|
||||||
self.check(CommunityPermission::MEMBER)
|
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 {
|
pub fn check_moderator(self) -> bool {
|
||||||
self.check(CommunityPermission::MANAGE_POSTS)
|
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 {
|
impl Default for CommunityPermission {
|
||||||
|
|
|
@ -34,10 +34,7 @@ pub fn render_markdown(input: &str) -> String {
|
||||||
.generic_attributes(allowed_attributes)
|
.generic_attributes(allowed_attributes)
|
||||||
.clean(&html)
|
.clean(&html)
|
||||||
.to_string()
|
.to_string()
|
||||||
.replace(
|
.replace("src=\"", "loading=\"lazy\" src=\"/api/v1/util/proxy?url=")
|
||||||
"src=\"",
|
|
||||||
"loading=\"lazy\" src=\"/api/v1/util/ext/image?img=",
|
|
||||||
)
|
|
||||||
.replace("-->", "<align class=\"right\">")
|
.replace("-->", "<align class=\"right\">")
|
||||||
.replace("->", "<align class=\"center\">")
|
.replace("->", "<align class=\"center\">")
|
||||||
.replace("<-", "</align>")
|
.replace("<-", "</align>")
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue