add: image proxy

add: mentions in posts
TODO: audit log, reports, user mod panel
This commit is contained in:
trisua 2025-04-01 13:26:33 -04:00
parent e183a01887
commit 3a8af17154
14 changed files with 308 additions and 33 deletions

View file

@ -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<RwLock<(DataManager, Tera)>>;
pub(crate) type State = Arc<RwLock<(DataManager, Tera, Client)>>;
fn render_markdown(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
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))

View file

@ -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(

View file

@ -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))

View file

@ -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()),

View 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",
)
}

View file

@ -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,
));
}
}
}
};
}

View file

@ -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<User>,
) -> String {
let lang = get_lang!(jar, data.0);