diff --git a/Cargo.lock b/Cargo.lock index 07b5df4..d0678f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -210,6 +210,7 @@ checksum = "de45108900e1f9b9242f7f2e254aa3e2c029c921c258fe9e6b4217eeebd54288" dependencies = [ "axum-core", "axum-macros", + "base64", "bytes", "form_urlencoded", "futures-util", @@ -229,8 +230,10 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", + "sha1", "sync_wrapper", "tokio", + "tokio-tungstenite", "tower", "tower-layer", "tower-service", @@ -812,6 +815,12 @@ dependencies = [ "syn", ] +[[package]] +name = "data-encoding" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e60eed09d8c01d3cee5b7d30acb059b76614c918fa0f992e0dd6eeb10daad6f" + [[package]] name = "deranged" version = "0.4.0" @@ -3224,13 +3233,14 @@ dependencies = [ [[package]] name = "tetratto" -version = "1.0.8" +version = "2.0.0" dependencies = [ "ammonia", "axum", "axum-extra", "cf-turnstile", "contrasted", + "futures-util", "image", "mime_guess", "pathbufd", @@ -3250,12 +3260,14 @@ dependencies = [ [[package]] name = "tetratto-core" -version = "1.0.8" +version = "2.0.0" dependencies = [ "async-recursion", "base16ct", + "base64", "bb8-postgres", "bitflags 2.9.0", + "futures-util", "md-5", "pathbufd", "redis", @@ -3272,7 +3284,7 @@ dependencies = [ [[package]] name = "tetratto-l10n" -version = "1.0.8" +version = "2.0.0" dependencies = [ "pathbufd", "serde", @@ -3281,7 +3293,7 @@ dependencies = [ [[package]] name = "tetratto-shared" -version = "1.0.8" +version = "2.0.0" dependencies = [ "ammonia", "chrono", @@ -3485,6 +3497,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4bf6fecd69fcdede0ec680aaf474cdab988f9de6bc73d3758f0160e3b7025a" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.14" @@ -3670,6 +3694,24 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413083a99c579593656008130e29255e54dcaae495be556cc26888f211648c24" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.5", + "sha1", + "thiserror 2.0.12", + "utf-8", +] + [[package]] name = "typed-arena" version = "2.0.2" diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 3732142..aad52e9 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto" -version = "1.0.8" +version = "2.0.0" edition = "2024" [features] @@ -16,7 +16,7 @@ tera = "1.20.0" tracing = "0.1.41" tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } tower-http = { version = "0.6.2", features = ["trace", "fs"] } -axum = { version = "0.8.3", features = ["macros"] } +axum = { version = "0.8.3", features = ["macros", "ws"] } tokio = { version = "1.44.2", features = ["macros", "rt-multi-thread"] } axum-extra = { version = "0.10.1", features = ["cookie", "multipart"] } ammonia = "4.1.0" @@ -33,3 +33,4 @@ serde_json = "1.0.140" mime_guess = "2.0.5" cf-turnstile = "0.2.0" contrasted = "0.1.2" +futures-util = "0.3.31" diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index 2ba3c38..c3a8d69 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -90,6 +90,10 @@ pub const MOD_IP_BANS: &str = include_str!("./public/html/mod/ip_bans.html"); pub const MOD_PROFILE: &str = include_str!("./public/html/mod/profile.html"); pub const MOD_WARNINGS: &str = include_str!("./public/html/mod/warnings.html"); +pub const CHATS_APP: &str = include_str!("./public/html/chats/app.html"); +pub const CHATS_STREAM: &str = include_str!("./public/html/chats/stream.html"); +pub const CHATS_MESSAGE: &str = include_str!("./public/html/chats/message.html"); + // langs pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml"); @@ -253,6 +257,10 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"mod/profile.html"(crate::assets::MOD_PROFILE) --config=config); write_template!(html_path->"mod/warnings.html"(crate::assets::MOD_WARNINGS) --config=config); + write_template!(html_path->"chats/app.html"(crate::assets::CHATS_APP) -d "chats" --config=config); + write_template!(html_path->"chats/stream.html"(crate::assets::CHATS_STREAM) --config=config); + write_template!(html_path->"chats/message.html"(crate::assets::CHATS_MESSAGE) --config=config); + html_path } diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index 87531fc..7adc6d8 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -60,6 +60,7 @@ version = "1.0.0" "auth:action.cancel_follow_request" = "Cancel follow request" "auth:label.blocked_profile" = "You're blocked" "auth:label.blocked_profile_message" = "This user has blocked you." +"auth:action.message" = "Message" "communities:action.create" = "Create" "communities:action.select" = "Select" @@ -100,6 +101,10 @@ version = "1.0.0" "communities:label.join_new" = "Join new" "communities:tab.posts" = "Posts" "communities:tab.questions" = "Questions" +"communities:tab.channels" = "Channels" +"communities:action.create_channel" = "Create channel" +"communities:label.chats" = "Chats" +"communities:label.show_community" = "Show community" "notifs:action.mark_as_read" = "Mark as read" "notifs:action.mark_as_unread" = "Mark as unread" @@ -142,3 +147,12 @@ version = "1.0.0" "requests:label.user_follow_request" = "User follow request" "requests:action.view_profile" = "View profile" "requests:label.user_follow_request_message" = "Accepting this request will not allow them to see your profile. For that, you must follow them back." + +"chats:label.my_chats" = "My chats" +"chats:action.move" = "Move" +"chats:action.rename" = "Rename" +"chats:label.view_older" = "View older" +"chats:label.view_more_recent" = "View more recent" +"chats:label.viewing_old_messages" = "You're viewing old messages!" +"chats:label.go_back" = "Go back" +"chats:action.leave" = "Leave" diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index dc17baa..236627d 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -34,7 +34,7 @@ fn check_supporter(value: &Value, _: &HashMap) -> tera::Result a, body .card-nest:not(.card *) > .card, body .banner { @@ -477,6 +477,12 @@ button.small, font-size: 16px; } +button.big_icon svg, +.button.big_icon svg { + height: 16px; + min-width: 16px; +} + button:hover, .button:hover { background: var(--color-primary-lowered); diff --git a/crates/app/src/public/html/auth/connection.html b/crates/app/src/public/html/auth/connection.html index e748ed9..b796522 100644 --- a/crates/app/src/public/html/auth/connection.html +++ b/crates/app/src/public/html/auth/connection.html @@ -30,6 +30,7 @@ config.connections.spotify_client_id %} refresh_token, expires_in: expires_in.toString(), name: profile.display_name, + url: profile.external_urls.spotify, }, ]); @@ -59,6 +60,7 @@ config.connections.last_fm_key %} { session_token: res.session.key, name: res.session.name, + url: `https://last.fm/user/${res.session.name}`, }, ]); diff --git a/crates/app/src/public/html/chats/app.html b/crates/app/src/public/html/chats/app.html new file mode 100644 index 0000000..5ba2150 --- /dev/null +++ b/crates/app/src/public/html/chats/app.html @@ -0,0 +1,573 @@ +{% extends "root.html" %} {% block head %} +Chats - {{ config.name }} +{% endblock %} {% block body %} {{ macros::nav(selected="chats") }} + + +
+ + + + + {% if channel %} +
+ + +
+ + + +
+
+ {% endif %} + + + + + + {% if selected_channel %} + + {% endif %} +
+{% endblock %} diff --git a/crates/app/src/public/html/chats/message.html b/crates/app/src/public/html/chats/message.html new file mode 100644 index 0000000..f040748 --- /dev/null +++ b/crates/app/src/public/html/chats/message.html @@ -0,0 +1,2 @@ +{%- import "components.html" as components -%} {{ components::message(user=user, +message=message) }} diff --git a/crates/app/src/public/html/chats/stream.html b/crates/app/src/public/html/chats/stream.html new file mode 100644 index 0000000..d646d20 --- /dev/null +++ b/crates/app/src/public/html/chats/stream.html @@ -0,0 +1,44 @@ +{%- import "components.html" as components -%} + + +
+ {% if page != 0 %} +
+ {{ text "chats:label.viewing_old_messages" }} + + {{ text "chats:label.go_back" }} + +
+ {% endif %} + + {% for message in messages %} + {{ components::message(user=message[1], message=message[0]) }} + {% endfor %} + + {% if messages|length > 0 %} + + {% endif %} +
+ + +
diff --git a/crates/app/src/public/html/communities/base.html b/crates/app/src/public/html/communities/base.html index 35d95be..0c77de6 100644 --- a/crates/app/src/public/html/communities/base.html +++ b/crates/app/src/public/html/communities/base.html @@ -179,6 +179,14 @@ {{ text "communities:action.leave" }} + + {{ icon "message-circle" }} + {{ text "communities:label.chats" }} + + + {% endif %} {% endif %} diff --git a/crates/app/src/public/js/me.js b/crates/app/src/public/js/me.js index a6f0f74..1e62c1f 100644 --- a/crates/app/src/public/js/me.js +++ b/crates/app/src/public/js/me.js @@ -364,18 +364,23 @@ }); self.define("push_con_shown", async (_, connection, shown) => { - return await ( - await fetch("/api/v1/auth/user/connections/_shown", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - connection, - shown, - }), - }) - ).json(); + fetch("/api/v1/auth/user/connections/_shown", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + connection, + shown, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger("atto::toast", [ + res.ok ? "success" : "error", + res.message, + ]); + }); }); })(); @@ -665,14 +670,9 @@ ]); } - const mb_info = await $.pull_track_info( - playing.artist.name, - playing.name, - ); - if ( window.localStorage.getItem("atto:connections.last_fm/name") === - playing.name + mb_info.id + playing.name ) { // item already pushed to connection, no need right now return; @@ -680,7 +680,12 @@ window.localStorage.setItem( "atto:connections.last_fm/name", - playing.name + mb_info.id, + playing.name, + ); + + const mb_info = await $.pull_track_info( + playing.artist.name, + playing.name, ); return await trigger("connections::push_con_state", [ diff --git a/crates/app/src/routes/api/v1/channels/channels.rs b/crates/app/src/routes/api/v1/channels/channels.rs new file mode 100644 index 0000000..2f836cf --- /dev/null +++ b/crates/app/src/routes/api/v1/channels/channels.rs @@ -0,0 +1,204 @@ +use axum::{Extension, Json, extract::Path, response::IntoResponse}; +use axum_extra::extract::CookieJar; +use tetratto_core::model::{channels::Channel, ApiReturn, Error}; +use crate::{ + get_user_from_token, + routes::api::v1::{ + CreateChannel, CreateGroupChannel, KickMember, UpdateChannelPosition, UpdateChannelTitle, + }, + State, +}; + +pub async fn create_request( + jar: CookieJar, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data + .create_channel(Channel::new( + match req.community.parse::() { + Ok(c) => c, + Err(e) => return Json(Error::MiscError(e.to_string()).into()), + }, + user.id, + 0, + req.title, + )) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Channel created".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn create_group_request( + jar: CookieJar, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let mut members: Vec = Vec::new(); + + for member in req.members { + members.push(match member.parse::() { + Ok(c) => c, + Err(e) => return Json(Error::MiscError(e.to_string()).into()), + }) + } + + // check for existing + if members.len() == 1 { + let other_user = members.get(0).unwrap().to_owned(); + if let Ok(channel) = data.get_channel_by_owner_member(user.id, other_user).await { + return Json(ApiReturn { + ok: true, + message: "Channel exists".to_string(), + payload: Some(channel.id.to_string()), + }); + } + } + + // check member permissions + for member in &members { + let other_user = match data.get_user_by_id(member.to_owned()).await { + Ok(ua) => ua, + Err(e) => return Json(e.into()), + }; + + if other_user.settings.private_chats { + if data + .get_userfollow_by_initiator_receiver(other_user.id, user.id) + .await + .is_err() + { + return Json(Error::NotAllowed.into()); + } + } + } + + // ... + let mut props = Channel::new(0, user.id, 0, req.title); + props.members = members; + let id = props.id.clone(); + + match data.create_channel(props).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Channel created".to_string(), + payload: Some(id.to_string()), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn delete_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.delete_channel(id, &user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Channel deleted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_title_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_channel_title(id, user, &req.title).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Channel updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_position_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_channel_position(id, user, req.position).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Channel updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn kick_member_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data + .remove_channel_member( + id, + user, + match req.member.parse::() { + Ok(c) => c, + Err(e) => return Json(Error::MiscError(e.to_string()).into()), + }, + ) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Member removed".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/channels/messages.rs b/crates/app/src/routes/api/v1/channels/messages.rs new file mode 100644 index 0000000..02f5d71 --- /dev/null +++ b/crates/app/src/routes/api/v1/channels/messages.rs @@ -0,0 +1,236 @@ +use axum::{ + extract::{ + ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}, + Path, + }, + response::{IntoResponse, Response}, + Extension, Json, +}; +use axum_extra::extract::CookieJar; +use tetratto_core::{ + cache::Cache, + model::{ + auth::User, + channels::Message, + socket::{SocketMessage, SocketMethod}, + ApiReturn, Error, + }, +}; +use std::sync::mpsc; +use crate::{get_user_from_token, routes::api::v1::CreateMessage, State}; +use serde::Deserialize; +use futures_util::{sink::SinkExt, stream::StreamExt}; + +#[derive(Deserialize)] +pub struct SocketHeaders { + pub channel: String, + pub user: String, +} + +/// Handle a subscription to the websocket. +pub async fn subscription_handler( + ws: WebSocketUpgrade, + Extension(data): Extension, + Path(channel_id): Path, +) -> Response { + ws.on_upgrade(move |socket| handle_socket(socket, data, channel_id)) +} + +pub async fn handle_socket(socket: WebSocket, state: State, channel_id: usize) { + let db = &(state.read().await).0; + let db = db.clone(); + + let (mut sink, mut stream) = socket.split(); + let (sender, receiver) = mpsc::channel::(); + + // forward messages from mpsc to the sink + tokio::spawn(async move { + while let Ok(message) = receiver.recv() { + if message == "Close" { + sink.close().await.unwrap(); + drop(receiver); + break; + } + + if sink.send(message.into()).await.is_err() { + break; + } + } + }); + + // ... + let mut user: Option = None; + let mut con = db.2.clone().get_con().await; + + // handle incoming messages on socket + let dbc = db.clone(); + let recv_sender = sender.clone(); + let mut recv_task = tokio::spawn(async move { + while let Some(Ok(WsMessage::Text(text))) = stream.next().await { + if text == "Pong" { + continue; + } + + if text == "Close" { + recv_sender.send("Close".to_string()).unwrap(); + break; + } + + let data: SocketMessage = match serde_json::from_str(&text.to_string()) { + Ok(t) => t, + Err(_) => { + recv_sender.send("Close".to_string()).unwrap(); + break; + } + }; + + if data.method != SocketMethod::Headers && user.is_none() { + // we've sent something else before authenticating... that's not right + recv_sender.send("Close".to_string()).unwrap(); + break; + } + + match data.method { + SocketMethod::Headers => { + let data: SocketHeaders = data.data(); + + user = Some( + match dbc + .get_user_by_id(match data.user.parse::() { + Ok(c) => c, + Err(_) => { + recv_sender.send("Close".to_string()).unwrap(); + break; + } + }) + .await + { + Ok(ua) => ua, + Err(_) => { + recv_sender.send("Close".to_string()).unwrap(); + break; + } + }, + ); + + let channel = match dbc + .get_channel_by_id(match data.channel.parse::() { + Ok(c) => c, + Err(_) => { + recv_sender.send("Close".to_string()).unwrap(); + break; + } + }) + .await + { + Ok(c) => c, + Err(_) => { + recv_sender.send("Close".to_string()).unwrap(); + break; + } + }; + + let user = user.as_ref().unwrap(); + + let membership = match dbc + .get_membership_by_owner_community(user.id, channel.id) + .await + { + Ok(ua) => ua, + Err(_) => { + recv_sender.send("Close".to_string()).unwrap(); + break; + } + }; + + if !channel.check_read(user.id, Some(membership.role)) { + recv_sender.send("Close".to_string()).unwrap(); + break; + } + } + _ => { + recv_sender.send("Close".to_string()).unwrap(); + break; + } + } + } + }); + + // forward messages from redis to the mpsc + let send_task_sender = sender.clone(); + let mut send_task = tokio::spawn(async move { + let mut pubsub = con.as_pubsub(); + pubsub.subscribe(channel_id).unwrap(); + + loop { + while let Ok(msg) = pubsub.get_message() { + // payload is a stringified SocketMessage + if send_task_sender.send(msg.get_payload().unwrap()).is_err() { + break; + } + } + } + }); + + // ... + let close_sender = sender.clone(); + tokio::select! { + _ = (&mut send_task) => recv_task.abort(), + _ = (&mut recv_task) => { + let _ = close_sender.send("Close".to_string()); + send_task.abort() + }, + }; +} + +pub async fn create_request( + jar: CookieJar, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data + .create_message(Message::new( + match req.channel.parse::() { + Ok(c) => c, + Err(e) => return Json(Error::MiscError(e.to_string()).into()), + }, + user.id, + req.content, + )) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Message created".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn delete_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.delete_message(id, user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Message deleted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/channels/mod.rs b/crates/app/src/routes/api/v1/channels/mod.rs new file mode 100644 index 0000000..345897d --- /dev/null +++ b/crates/app/src/routes/api/v1/channels/mod.rs @@ -0,0 +1,2 @@ +pub mod channels; +pub mod messages; diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index f943601..89a214e 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -6,9 +6,12 @@ pub mod reports; pub mod requests; pub mod util; +#[cfg(feature = "redis")] +pub mod channels; + use axum::{ + routing::{any, delete, get, post}, Router, - routing::{delete, get, post}, }; use serde::Deserialize; use tetratto_core::model::{ @@ -266,6 +269,32 @@ pub fn routes() -> Router { "/auth/user/connections/last_fm/api_proxy", post(auth::connections::last_fm::proxy_request), ) + // channels + .route("/channels", post(channels::channels::create_request)) + .route( + "/channels/group", + post(channels::channels::create_group_request), + ) + .route( + "/channels/{id}/title", + post(channels::channels::update_title_request), + ) + .route( + "/channels/{id}/move", + post(channels::channels::update_position_request), + ) + .route("/channels/{id}", delete(channels::channels::delete_request)) + .route( + "/channels/{id}/kick", + post(channels::channels::kick_member_request), + ) + // messages + .route( + "/channels/{id}/ws", + any(channels::messages::subscription_handler), + ) + .route("/messages", post(channels::messages::create_request)) + .route("/messages/{id}", delete(channels::messages::delete_request)) } #[derive(Deserialize)] @@ -419,3 +448,36 @@ pub struct CreateQuestion { #[serde(default)] pub community: String, } + +#[derive(Deserialize)] +pub struct CreateChannel { + pub title: String, + pub community: String, +} + +#[derive(Deserialize)] +pub struct CreateGroupChannel { + pub title: String, + pub members: Vec, +} + +#[derive(Deserialize)] +pub struct UpdateChannelTitle { + pub title: String, +} + +#[derive(Deserialize)] +pub struct UpdateChannelPosition { + pub position: i32, +} + +#[derive(Deserialize)] +pub struct CreateMessage { + pub content: String, + pub channel: String, +} + +#[derive(Deserialize)] +pub struct KickMember { + pub member: String, +} diff --git a/crates/app/src/routes/pages/chats.rs b/crates/app/src/routes/pages/chats.rs new file mode 100644 index 0000000..17da35e --- /dev/null +++ b/crates/app/src/routes/pages/chats.rs @@ -0,0 +1,249 @@ +use super::{render_error, PaginatedQuery}; +use crate::{State, assets::initial_context, get_lang, get_user_from_token}; +use axum::{ + extract::{Path, Query}, + response::{Html, IntoResponse, Redirect}, + Extension, Json, +}; +use axum_extra::extract::CookieJar; +use tetratto_core::model::{ + channels::Message, communities_permissions::CommunityPermission, permissions::FinePermission, + Error, +}; +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct RenderMessage { + pub data: String, +} + +pub async fn redirect_request() -> impl IntoResponse { + Redirect::to("/chats/0/0") +} + +/// `/chats/{community}/{channel}` +/// +/// `/chats/0` is for channels the user is part of (not in a community) +pub async fn app_request( + jar: CookieJar, + Extension(data): Extension, + Path((selected_community, selected_channel)): Path<(usize, usize)>, + Query(props): Query, +) -> impl IntoResponse { + let data = data.read().await; + let user = match get_user_from_token!(jar, data.0) { + Some(ua) => ua, + None => { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &None).await, + )); + } + }; + + let membership = match data + .0 + .get_membership_by_owner_community(user.id, selected_community) + .await + { + Ok(m) => m, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + let can_manage_channels = membership.role.check(CommunityPermission::MANAGE_CHANNELS) + | user.permissions.check(FinePermission::MANAGE_CHANNELS); + + let communities = match data.0.get_memberships_by_owner(user.id).await { + Ok(p) => match data.0.fill_communities(p).await { + Ok(p) => p, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + let channels = if selected_community == 0 { + match data.0.get_channels_by_user(user.id).await { + Ok(p) => p, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + } + } else { + match data.0.get_channels_by_community(selected_community).await { + Ok(p) => p, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + } + }; + + let community = if selected_community != 0 { + match data.0.get_community_by_id(selected_community).await { + Ok(p) => Some(p), + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + } + } else { + None + }; + + let channel = if selected_channel != 0 { + match data.0.get_channel_by_id(selected_channel).await { + Ok(p) => { + if !p.check_read(user.id, Some(membership.role)) { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &Some(user)).await, + )); + } + + Some(p) + } + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + } + } else { + None + }; + + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0, lang, &Some(user.clone())).await; + + context.insert("selected_community", &selected_community); + context.insert("selected_channel", &selected_channel); + context.insert("membership_role", &membership.role.bits()); + context.insert("page", &props.page); + + context.insert( + "can_manage_channels", + &if selected_community == 0 { + false + } else { + can_manage_channels + }, + ); + + context.insert( + "can_manage_channel", + &if selected_community == 0 { + if let Some(ref channel) = channel { + channel.members.contains(&user.id) | (channel.owner == user.id) + } else { + false + } + } else { + can_manage_channels + }, + ); + + context.insert("community", &community); + context.insert("channel", &channel); + + context.insert("communities", &communities); + context.insert("channels", &channels); + + // return + Ok(Html(data.1.render("chats/app.html", &context).unwrap())) +} + +/// `/chats/{community}/{channel}/_stream` +pub async fn stream_request( + jar: CookieJar, + Extension(data): Extension, + Path((community, channel)): Path<(usize, usize)>, + Query(props): Query, +) -> impl IntoResponse { + let data = data.read().await; + let user = match get_user_from_token!(jar, data.0) { + Some(ua) => ua, + None => { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &None).await, + )); + } + }; + + let ignore_users = data.0.get_userblocks_receivers(user.id).await; + let messages = match data + .0 + .get_messages_by_channel(channel, 12, props.page) + .await + { + Ok(p) => match data.0.fill_messages(p, &ignore_users).await { + Ok(p) => p, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + let membership = match data + .0 + .get_membership_by_owner_community(user.id, community) + .await + { + Ok(m) => m, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + let can_manage_messages = membership.role.check(CommunityPermission::MANAGE_MESSAGES) + | user.permissions.check(FinePermission::MANAGE_MESSAGES); + + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0, lang, &Some(user)).await; + + context.insert("messages", &messages); + context.insert("can_manage_messages", &can_manage_messages); + + context.insert("page", &props.page); + context.insert("community", &community); + context.insert("channel", &channel); + + // return + Ok(Html(data.1.render("chats/stream.html", &context).unwrap())) +} + +/// `/chats/{community}/{channel}/_render` +pub async fn message_request( + jar: CookieJar, + Extension(data): Extension, + Path((community, _)): Path<(usize, usize)>, + Json(req): Json, +) -> impl IntoResponse { + let data = data.read().await; + let user = match get_user_from_token!(jar, data.0) { + Some(ua) => ua, + None => { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &None).await, + )); + } + }; + + let message: Message = match serde_json::from_str(&req.data) { + Ok(m) => m, + Err(e) => { + return Err(Html( + render_error(Error::MiscError(e.to_string()), &jar, &data, &Some(user)).await, + )); + } + }; + + let membership = match data + .0 + .get_membership_by_owner_community(user.id, community) + .await + { + Ok(m) => m, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + let can_manage_messages = membership.role.check(CommunityPermission::MANAGE_MESSAGES) + | user.permissions.check(FinePermission::MANAGE_MESSAGES); + + let owner = match data.0.get_user_by_id(message.owner).await { + Ok(p) => p, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0, lang, &Some(user)).await; + + context.insert("can_manage_messages", &can_manage_messages); + context.insert("message", &message); + context.insert("user", &owner); + + // return + Ok(Html(data.1.render("chats/message.html", &context).unwrap())) +} diff --git a/crates/app/src/routes/pages/communities.rs b/crates/app/src/routes/pages/communities.rs index 0b29bf7..3f18e36 100644 --- a/crates/app/src/routes/pages/communities.rs +++ b/crates/app/src/routes/pages/communities.rs @@ -525,6 +525,14 @@ pub async fn settings_request( )); } + let channels = match data.0.get_channels_by_community(community.id).await { + Ok(p) => p, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + let can_manage_channels = membership.role.check(CommunityPermission::MANAGE_CHANNELS) + | user.permissions.check(FinePermission::MANAGE_CHANNELS); + // init context let lang = get_lang!(jar, data.0); let mut context = initial_context(&data.0.0, lang, &Some(user)).await; @@ -535,6 +543,9 @@ pub async fn settings_request( &clean_context(&community.context), ); + context.insert("can_manage_channels", &can_manage_channels); + context.insert("channels", &channels); + // return Ok(Html( data.1 diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index 7da4969..c2dec85 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -4,7 +4,13 @@ pub mod misc; pub mod mod_panel; pub mod profile; -use axum::{Router, routing::get}; +#[cfg(feature = "redis")] +pub mod chats; + +use axum::{ + routing::{get, post}, + Router, +}; use axum_extra::extract::CookieJar; use serde::Deserialize; use tetratto_core::{ @@ -82,6 +88,17 @@ pub fn routes() -> Router { .route("/post/{id}", get(communities::post_request)) .route("/post/{id}/reposts", get(communities::reposts_request)) .route("/question/{id}", get(communities::question_request)) + // chats + .route("/chats", get(chats::redirect_request)) + .route("/chats/{community}/{channel}", get(chats::app_request)) + .route( + "/chats/{community}/{channel}/_stream", + get(chats::stream_request), + ) + .route( + "/chats/{community}/{channel}/_render", + post(chats::message_request), + ) } pub async fn render_error( diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 8687aac..a7e3769 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-core" -version = "1.0.8" +version = "2.0.0" edition = "2024" [features] @@ -29,3 +29,5 @@ rusqlite = { version = "0.35.0", optional = true } tokio-postgres = { version = "0.7.13", optional = true } bb8-postgres = { version = "0.9.0", optional = true } +base64 = "0.22.1" +futures-util = "0.3.31" diff --git a/crates/core/src/database/channels.rs b/crates/core/src/database/channels.rs index a73146a..a99c58a 100644 --- a/crates/core/src/database/channels.rs +++ b/crates/core/src/database/channels.rs @@ -1,5 +1,6 @@ use super::*; use crate::cache::Cache; +use crate::model::moderation::AuditLogEntry; use crate::model::{ Error, Result, auth::User, permissions::FinePermission, communities_permissions::CommunityPermission, channels::Channel, @@ -26,12 +27,14 @@ impl DataManager { minimum_role_read: get!(x->4(i32)) as u32, minimum_role_write: get!(x->5(i32)) as u32, position: get!(x->6(i32)) as usize, + members: serde_json::from_str(&get!(x->7(String))).unwrap(), + title: get!(x->8(String)), } } - auto_method!(get_channel_by_id(usize)@get_channel_from_row -> "SELECT * FROM channels WHERE id = $1" --name="channel" --returns=Channel --cache-key-tmpl="atto.channel:{}"); + auto_method!(get_channel_by_id(usize as i64)@get_channel_from_row -> "SELECT * FROM channels WHERE id = $1" --name="channel" --returns=Channel --cache-key-tmpl="atto.channel:{}"); - /// Get all channels by user. + /// Get all channels by community. /// /// # Arguments /// * `community` - the ID of the community to fetch channels for @@ -43,7 +46,7 @@ impl DataManager { let res = query_rows!( &conn, - "SELECT * FROM channels WHERE community = $1 ORDER BY position DESC", + "SELECT * FROM channels WHERE community = $1 ORDER BY position ASC", &[&(community as i64)], |x| { Self::get_channel_from_row(x) } ); @@ -55,6 +58,59 @@ impl DataManager { Ok(res.unwrap()) } + /// Get all channels by user. + /// + /// # Arguments + /// * `user` - the ID of the user to fetch channels for + pub async fn get_channels_by_user(&self, user: usize) -> Result> { + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_rows!( + &conn, + "SELECT * FROM channels WHERE (owner = $1 OR members LIKE $2) AND community = 0 ORDER BY created DESC", + params![&(user as i64), &format!("%{user}%")], + |x| { Self::get_channel_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("channel".to_string())); + } + + Ok(res.unwrap()) + } + + /// Get a channel given its `owner` and a member. + /// + /// # Arguments + /// * `owner` - the ID of the owner + /// * `member` - the ID of the member + pub async fn get_channel_by_owner_member( + &self, + owner: usize, + member: usize, + ) -> Result { + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_row!( + &conn, + "SELECT * FROM channels WHERE owner = $1 AND members = $2 AND community = 0 ORDER BY created DESC", + params![&(owner as i64), &format!("[{member}]")], + |x| { Ok(Self::get_channel_from_row(x)) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("channel".to_string())); + } + + Ok(res.unwrap()) + } + /// Create a new channel in the database. /// /// # Arguments @@ -63,14 +119,16 @@ impl DataManager { let user = self.get_user_by_id(data.owner).await?; // check user permission in community - let membership = self - .get_membership_by_owner_community(user.id, data.community) - .await?; + if data.community != 0 { + let membership = self + .get_membership_by_owner_community(user.id, data.community) + .await?; - if !membership.role.check(CommunityPermission::MANAGE_CHANNELS) - && !user.permissions.check(FinePermission::MANAGE_CHANNELS) - { - return Err(Error::NotAllowed); + if !membership.role.check(CommunityPermission::MANAGE_CHANNELS) + && !user.permissions.check(FinePermission::MANAGE_CHANNELS) + { + return Err(Error::NotAllowed); + } } // ... @@ -81,7 +139,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO channels VALUES ($1, $2, $3, $4, $5, $6, $7)", + "INSERT INTO channels VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", params![ &(data.id as i64), &(data.community as i64), @@ -89,7 +147,9 @@ impl DataManager { &(data.created as i64), &(data.minimum_role_read as i32), &(data.minimum_role_write as i32), - &(data.position as i32) + &(data.position as i32), + &serde_json::to_string(&data.members).unwrap(), + &data.title ] ); @@ -100,16 +160,18 @@ impl DataManager { Ok(()) } - pub async fn delete_channel(&self, id: usize, user: User) -> Result<()> { + pub async fn delete_channel(&self, id: usize, user: &User) -> Result<()> { let channel = self.get_channel_by_id(id).await?; // check user permission in community - let membership = self - .get_membership_by_owner_community(user.id, channel.community) - .await?; + if user.id != channel.owner { + let membership = self + .get_membership_by_owner_community(user.id, channel.community) + .await?; - if !membership.role.check(CommunityPermission::MANAGE_CHANNELS) { - return Err(Error::NotAllowed); + if !membership.role.check(CommunityPermission::MANAGE_CHANNELS) { + return Err(Error::NotAllowed); + } } // ... @@ -124,11 +186,63 @@ impl DataManager { return Err(Error::DatabaseError(e.to_string())); } + // delete messages + let res = execute!( + &conn, + "DELETE FROM messages WHERE channel = $1", + &[&(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // ... self.2.remove(format!("atto.channel:{}", id)).await; Ok(()) } - auto_method!(update_channel_position(i32)@get_channel_by_id:MANAGE_COMMUNITIES -> "UPDATE channels SET position = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}"); - auto_method!(update_channel_minimum_role_read(i32)@get_channel_by_id:MANAGE_COMMUNITIES -> "UPDATE channels SET minimum_role_read = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}"); - auto_method!(update_channel_minimum_role_write(i32)@get_channel_by_id:MANAGE_COMMUNITIES -> "UPDATE channels SET minimum_role_write = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}"); + pub async fn remove_channel_member(&self, id: usize, user: User, member: usize) -> Result<()> { + let mut y = self.get_channel_by_id(id).await?; + + if user.id != y.owner && member != user.id { + if !user.permissions.check(FinePermission::MANAGE_CHANNELS) { + return Err(Error::NotAllowed); + } else { + self.create_audit_log_entry(AuditLogEntry::new( + user.id, + format!("invoked `remove_channel_member` with x value `{member}`"), + )) + .await? + } + } + + y.members + .remove(y.members.iter().position(|x| *x == member).unwrap()); + + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "UPDATE channels SET members = $1 WHERE id = $2", + params![&serde_json::to_string(&y.members).unwrap(), &(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.2.remove(format!("atto.channel:{}", id)).await; + + Ok(()) + } + + auto_method!(update_channel_title(&str)@get_channel_by_id:MANAGE_CHANNELS -> "UPDATE channels SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}"); + auto_method!(update_channel_position(i32)@get_channel_by_id:MANAGE_CHANNELS -> "UPDATE channels SET position = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}"); + auto_method!(update_channel_minimum_role_read(i32)@get_channel_by_id:MANAGE_CHANNELS -> "UPDATE channels SET minimum_role_read = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}"); + auto_method!(update_channel_minimum_role_write(i32)@get_channel_by_id:MANAGE_CHANNELS -> "UPDATE channels SET minimum_role_write = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}"); + auto_method!(update_channel_members(Vec)@get_channel_by_id:MANAGE_CHANNELS -> "UPDATE channels SET members = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.channel:{}"); } diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index 2137092..42296f9 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -3,7 +3,6 @@ use crate::{ execute, model::{Error, Result}, }; - use super::DataManager; impl DataManager { @@ -357,7 +356,7 @@ macro_rules! auto_method { let res = execute!( &conn, $query, - &[&serde_json::to_string(&x).unwrap(), &(id as i64)] + params![&serde_json::to_string(&x).unwrap(), &(id as i64)] ); if let Err(e) = res { diff --git a/crates/core/src/database/communities.rs b/crates/core/src/database/communities.rs index 2f0e6ff..1dc8ee7 100644 --- a/crates/core/src/database/communities.rs +++ b/crates/core/src/database/communities.rs @@ -330,6 +330,11 @@ impl DataManager { return Err(Error::DatabaseError(e.to_string())); } + // remove channels + for channel in self.get_channels_by_community(id).await? { + self.delete_channel(channel.id, &user).await?; + } + // remove images let avatar = PathBufD::current().extend(&[ self.0.dirs.media.as_str(), diff --git a/crates/core/src/database/drivers/sql/create_channels.sql b/crates/core/src/database/drivers/sql/create_channels.sql index 2198c71..6b8a29b 100644 --- a/crates/core/src/database/drivers/sql/create_channels.sql +++ b/crates/core/src/database/drivers/sql/create_channels.sql @@ -5,5 +5,7 @@ CREATE TABLE IF NOT EXISTS channels ( created BIGINT NOT NULL, minimum_role_read INT NOT NULL, minimum_role_write INT NOT NULL, - position INT NOT NULL + position INT NOT NULL, + members TEXT NOT NULL, + title TEXT NOT NULL ) diff --git a/crates/core/src/database/messages.rs b/crates/core/src/database/messages.rs index 3dc00bd..6c224ba 100644 --- a/crates/core/src/database/messages.rs +++ b/crates/core/src/database/messages.rs @@ -1,11 +1,23 @@ +use std::collections::HashMap; + use super::*; use crate::cache::Cache; use crate::model::moderation::AuditLogEntry; +use crate::model::socket::{SocketMessage, SocketMethod}; use crate::model::{ Error, Result, auth::User, permissions::FinePermission, communities_permissions::CommunityPermission, channels::Message, }; use crate::{auto_method, execute, get, query_row, query_rows, params}; +use serde::Serialize; + +#[derive(Serialize)] +struct DeleteMessageEvent { + pub id: String, +} + +#[cfg(feature = "redis")] +use redis::Commands; #[cfg(feature = "sqlite")] use rusqlite::Row; @@ -31,7 +43,35 @@ impl DataManager { } } - auto_method!(get_message_by_id(usize)@get_message_from_row -> "SELECT * FROM messages WHERE id = $1" --name="message" --returns=Message --cache-key-tmpl="atto.message:{}"); + auto_method!(get_message_by_id(usize as i64)@get_message_from_row -> "SELECT * FROM messages WHERE id = $1" --name="message" --returns=Message --cache-key-tmpl="atto.message:{}"); + + /// Complete a vector of just messages with their owner as well. + pub async fn fill_messages( + &self, + messages: Vec, + ignore_users: &Vec, + ) -> Result> { + let mut out: Vec<(Message, User)> = Vec::new(); + + let mut users: HashMap = HashMap::new(); + for message in messages { + let owner = message.owner; + + if ignore_users.contains(&owner) { + continue; + } + + if let Some(user) = users.get(&owner) { + out.push((message, user.clone())); + } else { + let user = self.get_user_by_id(owner).await?; + users.insert(owner, user.clone()); + out.push((message, user)); + } + } + + Ok(out) + } /// Get all messages by channel (paginated). /// @@ -52,7 +92,7 @@ impl DataManager { let res = query_rows!( &conn, - "SELECT * FROM messages WHERE channel = $1 ORDER BY created DESC LIMIT $1 OFFSET $2", + "SELECT * FROM messages WHERE channel = $1 ORDER BY created DESC LIMIT $2 OFFSET $3", &[&(channel as i64), &(batch as i64), &((page * batch) as i64)], |x| { Self::get_message_from_row(x) } ); @@ -69,6 +109,14 @@ impl DataManager { /// # Arguments /// * `data` - a mock [`Message`] object to insert pub async fn create_message(&self, data: Message) -> Result<()> { + if data.content.len() < 2 { + return Err(Error::DataTooLong("content".to_string())); + } + + if data.content.len() > 2048 { + return Err(Error::DataTooLong("content".to_string())); + } + let user = self.get_user_by_id(data.owner).await?; let channel = self.get_channel_by_id(data.channel).await?; @@ -77,14 +125,8 @@ impl DataManager { .get_membership_by_owner_community(user.id, channel.community) .await?; - if !membership.role.check_member() { - return Err(Error::NotAllowed); - } - // check user permission to post in channel - let role = membership.role.bits(); - - if role < channel.minimum_role_write { + if !channel.check_post(user.id, Some(membership.role)) { return Err(Error::NotAllowed); } @@ -112,6 +154,21 @@ impl DataManager { return Err(Error::DatabaseError(e.to_string())); } + // post event + let mut con = self.2.get_con().await; + + if let Err(e) = con.publish::( + data.channel, + serde_json::to_string(&SocketMessage { + method: SocketMethod::Message, + data: serde_json::to_string(&data).unwrap(), + }) + .unwrap(), + ) { + return Err(Error::MiscError(e.to_string())); + } + + // ... Ok(()) } @@ -149,6 +206,22 @@ impl DataManager { } self.2.remove(format!("atto.message:{}", id)).await; + + // post event + let mut con = self.2.get_con().await; + + if let Err(e) = con.publish::( + message.channel, + serde_json::to_string(&SocketMessage { + method: SocketMethod::Delete, + data: serde_json::to_string(&DeleteMessageEvent { id: id.to_string() }).unwrap(), + }) + .unwrap(), + ) { + return Err(Error::MiscError(e.to_string())); + } + + // ... Ok(()) } diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index 2272c8d..de18098 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -190,9 +190,12 @@ pub struct UserSettings { /// If dislikes are hidden for the user. #[serde(default)] pub hide_dislikes: bool, - /// The timeline that the "Home" button takes you to + /// The timeline that the "Home" button takes you to. #[serde(default)] pub default_timeline: DefaultTimelineChoice, + /// If other users that you aren't following can add you to chats. + #[serde(default)] + pub private_chats: bool, } impl Default for User { @@ -352,10 +355,12 @@ pub enum ConnectionService { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub enum ConnectionType { - /// A connection through a token with an expiration time. + /// A connection through a token which never expires. Token, /// PKCE, + /// A connection with no stored authentication. + None, } #[derive(Clone, Debug, Serialize, Deserialize)] diff --git a/crates/core/src/model/channels.rs b/crates/core/src/model/channels.rs index 0c3c523..bde82d2 100644 --- a/crates/core/src/model/channels.rs +++ b/crates/core/src/model/channels.rs @@ -18,11 +18,17 @@ pub struct Channel { /// /// Top (0) to bottom. pub position: usize, + /// The members of the chat (ids). Should be empty if `community > 0`. + /// + /// The owner should not be a member of the channel since any member can update members. + pub members: Vec, + /// The title of the channel. + pub title: String, } impl Channel { /// Create a new [`Channel`]. - pub fn new(community: usize, owner: usize, position: usize) -> Self { + pub fn new(community: usize, owner: usize, position: usize, title: String) -> Self { Self { id: AlmostSnowflake::new(1234567890) .to_string() @@ -34,8 +40,32 @@ impl Channel { minimum_role_read: (CommunityPermission::DEFAULT | CommunityPermission::MEMBER).bits(), minimum_role_write: (CommunityPermission::DEFAULT | CommunityPermission::MEMBER).bits(), position, + members: Vec::new(), + title, } } + + /// Check if the given `uid` can post in the channel. + pub fn check_post(&self, uid: usize, membership: Option) -> bool { + let mut is_member = false; + + if let Some(membership) = membership { + is_member = membership.bits() >= self.minimum_role_write + } + + (uid == self.owner) | is_member | self.members.contains(&uid) + } + + /// Check if the given `uid` can post in the channel. + pub fn check_read(&self, uid: usize, membership: Option) -> bool { + let mut is_member = false; + + if let Some(membership) = membership { + is_member = membership.bits() >= self.minimum_role_read + } + + (uid == self.owner) | is_member | self.members.contains(&uid) + } } #[derive(Serialize, Deserialize)] diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs index 1de07b6..1a6dc80 100644 --- a/crates/core/src/model/mod.rs +++ b/crates/core/src/model/mod.rs @@ -2,6 +2,7 @@ pub mod auth; pub mod communities; pub mod communities_permissions; pub mod moderation; +pub mod oauth; pub mod permissions; pub mod reactions; pub mod requests; @@ -9,6 +10,9 @@ pub mod requests; #[cfg(feature = "redis")] pub mod channels; +#[cfg(feature = "redis")] +pub mod socket; + use std::fmt::Display; use serde::{Deserialize, Serialize}; diff --git a/crates/core/src/model/oauth.rs b/crates/core/src/model/oauth.rs new file mode 100644 index 0000000..f25ba8e --- /dev/null +++ b/crates/core/src/model/oauth.rs @@ -0,0 +1,36 @@ +use base64::{engine::general_purpose::URL_SAFE as base64url, Engine}; +use serde::{Serialize, Deserialize}; +use tetratto_shared::hash::hash; +use super::{Result, Error}; + +#[derive(Serialize, Deserialize, PartialEq, Eq)] +pub enum PkceChallengeMethod { + S256, +} + +#[derive(Serialize, Deserialize, PartialEq, Eq)] +pub enum AppScope { + #[serde(alias = "user-read-profile")] + UserReadProfile, +} + +/// Check a verifier against the stored challenge (using the given [`PkceChallengeMethod`]). +pub fn check_verifier(verifier: &str, challenge: &str, method: PkceChallengeMethod) -> Result<()> { + if method != PkceChallengeMethod::S256 { + return Err(Error::MiscError("only S256 is supported".to_string())); + } + + let decoded = match base64url.decode(challenge.as_bytes()) { + Ok(hash) => hash, + Err(e) => return Err(Error::MiscError(e.to_string())), + }; + + let hash = hash(verifier.to_string()); + + if hash.as_bytes() != decoded { + // the verifier we received does not match the verifier from the stored challenge + return Err(Error::NotAllowed); + } + + Ok(()) +} diff --git a/crates/core/src/model/socket.rs b/crates/core/src/model/socket.rs new file mode 100644 index 0000000..55fb8ad --- /dev/null +++ b/crates/core/src/model/socket.rs @@ -0,0 +1,23 @@ +use serde::{Serialize, Deserialize, de::DeserializeOwned}; + +#[derive(Serialize, Deserialize, PartialEq, Eq)] +pub enum SocketMethod { + /// Authentication and channel identification. + Headers, + /// A message was sent in the channel. + Message, + /// A message was deleted in the channel. + Delete, +} + +#[derive(Serialize, Deserialize)] +pub struct SocketMessage { + pub method: SocketMethod, + pub data: String, +} + +impl SocketMessage { + pub fn data(&self) -> T { + serde_json::from_str(&self.data).unwrap() + } +} diff --git a/crates/l10n/Cargo.toml b/crates/l10n/Cargo.toml index 31bde09..e4c52b4 100644 --- a/crates/l10n/Cargo.toml +++ b/crates/l10n/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-l10n" -version = "1.0.8" +version = "2.0.0" edition = "2024" authors.workspace = true repository.workspace = true diff --git a/crates/shared/Cargo.toml b/crates/shared/Cargo.toml index 2145340..7e85da3 100644 --- a/crates/shared/Cargo.toml +++ b/crates/shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-shared" -version = "1.0.8" +version = "2.0.0" edition = "2024" authors.workspace = true repository.workspace = true diff --git a/sql_changes/channels_members.sql b/sql_changes/channels_members.sql new file mode 100644 index 0000000..4d79f01 --- /dev/null +++ b/sql_changes/channels_members.sql @@ -0,0 +1,2 @@ +ALTER TABLE channels +ADD COLUMN members TEXT NOT NULL DEFAULT '[]'; diff --git a/sql_changes/channels_title.sql b/sql_changes/channels_title.sql new file mode 100644 index 0000000..0fad7b8 --- /dev/null +++ b/sql_changes/channels_title.sql @@ -0,0 +1,2 @@ +ALTER TABLE channels +ADD COLUMN title TEXT NOT NULL DEFAULT '';