57 Commits

Author SHA1 Message Date
5eab7c3e4f Process events list client side 2025-11-25 09:48:49 +01:00
a7bfd713c3 Ready to implement room widget 2025-11-24 17:59:12 +01:00
4be661d999 Fix appearance of unread conversations 2025-11-24 17:55:26 +01:00
1f4e374e66 Display rooms list 2025-11-24 17:50:31 +01:00
cce9b3de5d Hide menu by default on desktop 2025-11-24 16:36:36 +01:00
820b095be0 Display the list of spaces 2025-11-24 16:05:01 +01:00
0a37688116 Can react to event 2025-11-24 13:40:14 +01:00
4d72644a31 Can edit message 2025-11-24 13:18:23 +01:00
0a395b0d26 Can redact message 2025-11-24 13:06:31 +01:00
639cc6c737 Can send text message 2025-11-24 12:54:59 +01:00
bf119a34fb Can get room messages 2025-11-24 12:36:59 +01:00
7562a7fc61 Get latest message for a room 2025-11-24 11:20:20 +01:00
d23190f9d2 Can get spaces of user 2025-11-21 18:38:20 +01:00
35b53fee5c Can request any media file 2025-11-21 17:55:09 +01:00
934e6a4cc1 Can get multiple profiles information 2025-11-21 17:49:41 +01:00
b744265242 Can get single profile information 2025-11-21 17:14:23 +01:00
e8ce97eea0 Can get room avatar 2025-11-21 15:43:15 +01:00
ecbe4885c1 Can get information about rooms 2025-11-21 14:52:21 +01:00
1385afc974 Add more information to websocket messages 2025-11-21 11:40:51 +01:00
8d2cea5f82 Refactor messages propagation 2025-11-21 10:30:48 +01:00
751e3b8654 Redact more events 2025-11-21 09:35:21 +01:00
24f06a78a9 Block WS access if Matrix account is not linked 2025-11-21 09:12:13 +01:00
6b70842b61 Display state in color 2025-11-20 19:31:17 +01:00
7203671b18 Pretty rendering of JSON messages 2025-11-20 19:30:09 +01:00
055ab3759c Remove frontend messages 2025-11-20 19:15:42 +01:00
3ecfc6b470 Add base debug WS route 2025-11-20 19:14:02 +01:00
a1b22699e9 Basic implementation of websocket 2025-11-20 16:06:00 +01:00
0d8905d842 Can stop sync thread from UI 2025-11-19 18:41:26 +01:00
564e606ac7 Properly handle start sync thread issue 2025-11-19 17:15:54 +01:00
7b691962a0 Can get sync thread status 2025-11-19 16:34:00 +01:00
1e00d24a8b Can request sync thread stop 2025-11-19 15:51:15 +01:00
cfdf98b47a Matrix messages are broadcasted 2025-11-19 14:02:51 +01:00
75b6b224bc Notify Matrix manager directly if sync thread is terminated 2025-11-19 13:39:28 +01:00
07f6544a4a WIP sync thread implementation 2025-11-19 11:37:57 +01:00
5bf7c7f8df Do not start sync thread if user is disconnected 2025-11-19 10:49:26 +01:00
79d4482ea4 Sync threads can be interrupted 2025-11-19 10:27:46 +01:00
c9b703bea3 Ready to implement sync thread logic 2025-11-18 22:17:39 +01:00
5c13cffe08 Send broadcast message when an API token is deleted 2025-11-18 15:09:27 +01:00
b5832df746 Can delete API token from UI 2025-11-18 14:51:05 +01:00
02e5575892 Display the list of API tokens 2025-11-14 09:07:22 +01:00
2683268042 Load the list of API tokens 2025-11-13 21:16:45 +01:00
72aaf7b082 Add token creation dialog 2025-11-13 21:03:38 +01:00
c8a48488fc Fix session disconnection issue by removing automatic refresh on client initialization 2025-11-13 18:38:27 +01:00
3b7b368e13 Attempt to fix session restoration issues 2025-11-12 08:14:16 +01:00
5ca126eef7 Split recovery key dialog in new file 2025-11-12 08:06:47 +01:00
7c78eb541e Fix example API client 2025-11-11 21:24:19 +01:00
8fdf1d57eb Create & list tokens 2025-11-11 21:19:54 +01:00
b10ec9ce92 Cleanup code 2025-11-11 17:58:24 +01:00
7925785c8b Fix issue 2025-11-11 17:19:23 +01:00
84c90ea033 Can set user recovery key from UI 2025-11-10 17:42:32 +01:00
a23d671376 Can set recovery key of user 2025-11-10 08:47:02 +01:00
4a72411d65 Return encryption recovery status on API 2025-11-10 08:32:17 +01:00
70a246355b Can disconnect user from UI 2025-11-06 21:33:09 +01:00
8bbbe7022f Automatically disconnect user when token is invalid 2025-11-06 21:18:27 +01:00
1ba5372468 Restore user session on restart 2025-11-06 18:58:43 +01:00
1438e2de0e Can save Matrix session after authentication 2025-11-05 22:53:55 +01:00
1eaec9d319 Can finalize Matrix authentication 2025-11-05 19:32:11 +01:00
69 changed files with 4241 additions and 233 deletions

View File

@@ -241,6 +241,20 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "actix-ws"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3a1fb4f9f2794b0aadaf2ba5f14a6f034c7e86957b458c506a8cb75953f2d99"
dependencies = [
"actix-codec",
"actix-http",
"actix-web",
"bytestring",
"futures-core",
"tokio",
]
[[package]] [[package]]
name = "adler2" name = "adler2"
version = "2.0.1" version = "2.0.1"
@@ -763,6 +777,17 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f4c707c6a209cbe82d10abd08e1ea8995e9ea937d2550646e02798948992be0" checksum = "4f4c707c6a209cbe82d10abd08e1ea8995e9ea937d2550646e02798948992be0"
[[package]]
name = "cfb"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
dependencies = [
"byteorder",
"fnv",
"uuid",
]
[[package]] [[package]]
name = "cfg-if" name = "cfg-if"
version = "1.0.4" version = "1.0.4"
@@ -2343,6 +2368,15 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "infer"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7"
dependencies = [
"cfb",
]
[[package]] [[package]]
name = "inout" name = "inout"
version = "0.1.4" version = "0.1.4"
@@ -2530,6 +2564,29 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388"
[[package]]
name = "lazy-regex"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "191898e17ddee19e60bccb3945aa02339e81edd4a8c50e21fd4d48cdecda7b29"
dependencies = [
"lazy-regex-proc_macros",
"once_cell",
"regex",
]
[[package]]
name = "lazy-regex-proc_macros"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c35dc8b0da83d1a9507e12122c80dea71a9c7c613014347392483a83ea593e04"
dependencies = [
"proc-macro2",
"quote",
"regex",
"syn",
]
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.5.0" version = "1.5.0"
@@ -3003,6 +3060,7 @@ dependencies = [
"actix-remote-ip", "actix-remote-ip",
"actix-session", "actix-session",
"actix-web", "actix-web",
"actix-ws",
"anyhow", "anyhow",
"base16ct 0.3.0", "base16ct 0.3.0",
"bytes", "bytes",
@@ -3010,8 +3068,10 @@ dependencies = [
"env_logger", "env_logger",
"futures-util", "futures-util",
"hex", "hex",
"infer",
"ipnet", "ipnet",
"jwt-simple", "jwt-simple",
"lazy-regex",
"lazy_static", "lazy_static",
"light-openid", "light-openid",
"log", "log",
@@ -3020,11 +3080,11 @@ dependencies = [
"ractor", "ractor",
"rand 0.9.2", "rand 0.9.2",
"serde", "serde",
"serde_json",
"sha2", "sha2",
"thiserror 2.0.17", "thiserror 2.0.17",
"tokio", "tokio",
"url", "url",
"urlencoding",
"uuid", "uuid",
] ]

View File

@@ -18,7 +18,6 @@ actix-cors = "0.7.1"
light-openid = "1.0.4" light-openid = "1.0.4"
bytes = "1.10.1" bytes = "1.10.1"
sha2 = "0.10.9" sha2 = "0.10.9"
urlencoding = "2.1.3"
base16ct = { version = "0.3.0", features = ["alloc"] } base16ct = { version = "0.3.0", features = ["alloc"] }
futures-util = "0.3.31" futures-util = "0.3.31"
jwt-simple = { version = "0.12.13", default-features = false, features = ["pure-rust"] } jwt-simple = { version = "0.12.13", default-features = false, features = ["pure-rust"] }
@@ -28,6 +27,10 @@ ipnet = { version = "2.11.0", features = ["serde"] }
rand = "0.9.2" rand = "0.9.2"
hex = "0.4.3" hex = "0.4.3"
mailchecker = "6.0.19" mailchecker = "6.0.19"
matrix-sdk = "0.14.0" matrix-sdk = { version = "0.14.0" }
url = "2.5.7" url = "2.5.7"
ractor = "0.15.9" ractor = "0.15.9"
serde_json = "1.0.145"
lazy-regex = "3.4.2"
actix-ws = "0.3.0"
infer = "0.19.0"

View File

@@ -2,11 +2,13 @@ use clap::Parser;
use jwt_simple::algorithms::HS256Key; use jwt_simple::algorithms::HS256Key;
use jwt_simple::prelude::{Clock, Duration, JWTClaims, MACLike}; use jwt_simple::prelude::{Clock, Duration, JWTClaims, MACLike};
use matrixgw_backend::constants; use matrixgw_backend::constants;
use matrixgw_backend::extractors::auth_extractor::TokenClaims; use matrixgw_backend::extractors::auth_extractor::{MatrixJWTKID, TokenClaims};
use matrixgw_backend::users::{APITokenID, UserEmail};
use matrixgw_backend::utils::rand_utils::rand_string; use matrixgw_backend::utils::rand_utils::rand_string;
use std::ops::Add; use std::ops::Add;
use std::os::unix::prelude::CommandExt; use std::os::unix::prelude::CommandExt;
use std::process::Command; use std::process::Command;
use std::str::FromStr;
/// cURL wrapper to query MatrixGW /// cURL wrapper to query MatrixGW
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
@@ -20,9 +22,9 @@ struct Args {
#[arg(short('i'), long, env)] #[arg(short('i'), long, env)]
token_id: String, token_id: String,
/// User ID /// User email
#[arg(short('u'), long, env)] #[arg(short('u'), long, env)]
user_id: String, user_mail: String,
/// Token secret /// Token secret
#[arg(short('t'), long, env)] #[arg(short('t'), long, env)]
@@ -69,11 +71,14 @@ fn main() {
}; };
let jwt = key let jwt = key
.with_key_id(&format!( .with_key_id(
"{}#{}", &MatrixJWTKID {
urlencoding::encode(&args.user_id), user_email: UserEmail(args.user_mail),
urlencoding::encode(&args.token_id) id: APITokenID::from_str(args.token_id.as_str())
)) .expect("Failed to decode token ID!"),
}
.to_string(),
)
.authenticate(claims) .authenticate(claims)
.expect("Failed to sign JWT!"); .expect("Failed to sign JWT!");

View File

@@ -220,6 +220,11 @@ impl AppConfig {
pub fn user_matrix_passphrase_path(&self, mail: &UserEmail) -> PathBuf { pub fn user_matrix_passphrase_path(&self, mail: &UserEmail) -> PathBuf {
self.user_directory(mail).join("matrix-db-passphrase") self.user_directory(mail).join("matrix-db-passphrase")
} }
/// Get user Matrix session file path
pub fn user_matrix_session_file_path(&self, mail: &UserEmail) -> PathBuf {
self.user_directory(mail).join("matrix-session.json")
}
} }
#[derive(Debug, Clone, serde::Serialize)] #[derive(Debug, Clone, serde::Serialize)]

View File

@@ -0,0 +1,37 @@
use crate::matrix_connection::sync_thread::MatrixSyncTaskID;
use crate::users::{APIToken, UserEmail};
use matrix_sdk::Room;
use matrix_sdk::ruma::events::reaction::OriginalSyncReactionEvent;
use matrix_sdk::ruma::events::room::message::OriginalSyncRoomMessageEvent;
use matrix_sdk::ruma::events::room::redaction::OriginalSyncRoomRedactionEvent;
use matrix_sdk::sync::SyncResponse;
pub type BroadcastSender = tokio::sync::broadcast::Sender<BroadcastMessage>;
#[derive(Debug, Clone)]
pub struct BxRoomEvent<E> {
pub user: UserEmail,
pub data: Box<E>,
pub room: Room,
}
/// Broadcast messages
#[derive(Debug, Clone)]
pub enum BroadcastMessage {
/// User is or has been disconnected from Matrix
UserDisconnectedFromMatrix(UserEmail),
/// API token has been deleted
APITokenDeleted(APIToken),
/// Request a Matrix sync thread to be interrupted
StopSyncThread(MatrixSyncTaskID),
/// Matrix sync thread has been interrupted
SyncThreadStopped(MatrixSyncTaskID),
/// New room message
RoomMessageEvent(BxRoomEvent<OriginalSyncRoomMessageEvent>),
/// New reaction message
ReactionEvent(BxRoomEvent<OriginalSyncReactionEvent>),
/// New room redaction
RoomRedactionEvent(BxRoomEvent<OriginalSyncRoomRedactionEvent>),
/// Raw Matrix sync response
MatrixSyncResponse { user: UserEmail, sync: SyncResponse },
}

View File

@@ -1,9 +1,14 @@
use std::time::Duration;
/// Auth header /// Auth header
pub const API_AUTH_HEADER: &str = "x-client-auth"; pub const API_AUTH_HEADER: &str = "x-client-auth";
/// Max token validity, in seconds /// Max token validity, in seconds
pub const API_TOKEN_JWT_MAX_DURATION: u64 = 15 * 60; pub const API_TOKEN_JWT_MAX_DURATION: u64 = 15 * 60;
/// Length of generated tokens
pub const TOKENS_LEN: usize = 50;
/// Session-specific constants /// Session-specific constants
pub mod sessions { pub mod sessions {
/// OpenID auth session state key /// OpenID auth session state key
@@ -13,3 +18,11 @@ pub mod sessions {
/// Authenticated ID /// Authenticated ID
pub const USER_ID: &str = "uid"; pub const USER_ID: &str = "uid";
} }
/// How often heartbeat pings are sent.
///
/// Should be half (or less) of the acceptable client timeout.
pub const WS_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5);
/// How long before lack of client response causes a timeout.
pub const WS_CLIENT_TIMEOUT: Duration = Duration::from_secs(10);

View File

@@ -1,8 +1,10 @@
use crate::app_config::AppConfig; use crate::app_config::AppConfig;
use crate::broadcast_messages::BroadcastSender;
use crate::controllers::{HttpFailure, HttpResult}; use crate::controllers::{HttpFailure, HttpResult};
use crate::extractors::auth_extractor::{AuthExtractor, AuthenticatedMethod}; use crate::extractors::auth_extractor::{AuthExtractor, AuthenticatedMethod};
use crate::extractors::matrix_client_extractor::MatrixClientExtractor;
use crate::extractors::session_extractor::MatrixGWSession; use crate::extractors::session_extractor::MatrixGWSession;
use crate::users::{ExtendedUserInfo, User, UserEmail}; use crate::users::{User, UserEmail};
use actix_remote_ip::RemoteIP; use actix_remote_ip::RemoteIP;
use actix_web::{HttpResponse, web}; use actix_web::{HttpResponse, web};
use light_openid::primitives::OpenIDConfig; use light_openid::primitives::OpenIDConfig;
@@ -107,19 +109,23 @@ pub async fn finish_oidc(
} }
/// Get current user information /// Get current user information
pub async fn auth_info(auth: AuthExtractor) -> HttpResult { pub async fn auth_info(client: MatrixClientExtractor) -> HttpResult {
Ok(HttpResponse::Ok().json(ExtendedUserInfo::from_user(auth.user).await?)) Ok(HttpResponse::Ok().json(client.to_extended_user_info().await?))
} }
/// Sign out user /// Sign out user
pub async fn sign_out(auth: AuthExtractor, session: MatrixGWSession) -> HttpResult { pub async fn sign_out(
auth: AuthExtractor,
session: MatrixGWSession,
tx: web::Data<BroadcastSender>,
) -> HttpResult {
match auth.method { match auth.method {
AuthenticatedMethod::Cookie => { AuthenticatedMethod::Cookie => {
session.unset_current_user()?; session.unset_current_user()?;
} }
AuthenticatedMethod::Token(token) => { AuthenticatedMethod::Token(token) => {
token.delete(&auth.user.email).await?; token.delete(&auth.user.email, &tx).await?;
} }
AuthenticatedMethod::Dev => { AuthenticatedMethod::Dev => {

View File

@@ -0,0 +1,204 @@
use crate::controllers::HttpResult;
use crate::controllers::matrix::matrix_room_controller::RoomIdInPath;
use crate::extractors::matrix_client_extractor::MatrixClientExtractor;
use actix_web::{HttpResponse, web};
use futures_util::{StreamExt, stream};
use matrix_sdk::Room;
use matrix_sdk::deserialized_responses::{TimelineEvent, TimelineEventKind};
use matrix_sdk::room::MessagesOptions;
use matrix_sdk::room::edit::EditedContent;
use matrix_sdk::ruma::events::reaction::ReactionEventContent;
use matrix_sdk::ruma::events::relation::Annotation;
use matrix_sdk::ruma::events::room::message::{
RoomMessageEventContent, RoomMessageEventContentWithoutRelation,
};
use matrix_sdk::ruma::{MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedUserId, RoomId, UInt};
use serde::{Deserialize, Serialize};
use serde_json::value::RawValue;
#[derive(Serialize)]
pub struct APIEvent {
id: OwnedEventId,
time: MilliSecondsSinceUnixEpoch,
sender: OwnedUserId,
data: Box<RawValue>,
}
impl APIEvent {
pub async fn from_evt(msg: TimelineEvent, room_id: &RoomId) -> anyhow::Result<Self> {
let (event, raw) = match &msg.kind {
TimelineEventKind::Decrypted(d) => (d.event.deserialize()?, d.event.json()),
TimelineEventKind::UnableToDecrypt { event, .. }
| TimelineEventKind::PlainText { event } => (
event.deserialize()?.into_full_event(room_id.to_owned()),
event.json(),
),
};
Ok(Self {
id: event.event_id().to_owned(),
time: event.origin_server_ts(),
sender: event.sender().to_owned(),
data: raw.to_owned(),
})
}
}
#[derive(Serialize)]
pub struct APIEventsList {
pub start: String,
pub end: Option<String>,
pub events: Vec<APIEvent>,
}
/// Get messages for a given room
pub(super) async fn get_events(
room: &Room,
limit: u32,
from: Option<&str>,
) -> anyhow::Result<APIEventsList> {
let mut msg_opts = MessagesOptions::backward();
msg_opts.from = from.map(str::to_string);
msg_opts.limit = UInt::from(limit);
let messages = room.messages(msg_opts).await?;
Ok(APIEventsList {
start: messages.start,
end: messages.end,
events: stream::iter(messages.chunk)
.then(async |msg| APIEvent::from_evt(msg, room.room_id()).await)
.collect::<Vec<_>>()
.await
.into_iter()
.collect::<Result<Vec<_>, _>>()?,
})
}
#[derive(Deserialize)]
pub struct GetRoomEventsQuery {
#[serde(default)]
limit: Option<u32>,
#[serde(default)]
from: Option<String>,
}
/// Get the events for a room
pub async fn get_for_room(
client: MatrixClientExtractor,
path: web::Path<RoomIdInPath>,
query: web::Query<GetRoomEventsQuery>,
) -> HttpResult {
let Some(room) = client.client.client.get_room(&path.room_id) else {
return Ok(HttpResponse::NotFound().json("Room not found!"));
};
Ok(HttpResponse::Ok()
.json(get_events(&room, query.limit.unwrap_or(500), query.from.as_deref()).await?))
}
#[derive(Deserialize)]
struct SendTextMessageRequest {
content: String,
}
pub async fn send_text_message(
client: MatrixClientExtractor,
path: web::Path<RoomIdInPath>,
) -> HttpResult {
let req = client.auth.decode_json_body::<SendTextMessageRequest>()?;
let Some(room) = client.client.client.get_room(&path.room_id) else {
return Ok(HttpResponse::NotFound().json("Room not found!"));
};
room.send(RoomMessageEventContent::text_plain(req.content))
.await?;
Ok(HttpResponse::Accepted().finish())
}
#[derive(serde::Deserialize)]
pub struct EventIdInPath {
pub(crate) event_id: OwnedEventId,
}
pub async fn set_text_content(
client: MatrixClientExtractor,
path: web::Path<RoomIdInPath>,
event_path: web::Path<EventIdInPath>,
) -> HttpResult {
let req = client.auth.decode_json_body::<SendTextMessageRequest>()?;
let Some(room) = client.client.client.get_room(&path.room_id) else {
return Ok(HttpResponse::NotFound().json("Room not found!"));
};
let edit_event = match room
.make_edit_event(
&event_path.event_id,
EditedContent::RoomMessage(RoomMessageEventContentWithoutRelation::text_plain(
req.content,
)),
)
.await
{
Ok(msg) => msg,
Err(e) => {
log::error!(
"Failed to created edit message event {}: {e}",
event_path.event_id
);
return Ok(HttpResponse::InternalServerError()
.json(format!("Failed to create edit message event! {e}")));
}
};
Ok(match room.send(edit_event).await {
Ok(_) => HttpResponse::Accepted().finish(),
Err(e) => {
log::error!("Failed to edit event message {}: {e}", event_path.event_id);
HttpResponse::InternalServerError().json(format!("Failed to edit event! {e}"))
}
})
}
#[derive(Deserialize)]
struct EventReactionBody {
key: String,
}
pub async fn react_to_event(
client: MatrixClientExtractor,
path: web::Path<RoomIdInPath>,
event_path: web::Path<EventIdInPath>,
) -> HttpResult {
let body = client.auth.decode_json_body::<EventReactionBody>()?;
let Some(room) = client.client.client.get_room(&path.room_id) else {
return Ok(HttpResponse::NotFound().json("Room not found!"));
};
let annotation = Annotation::new(event_path.event_id.to_owned(), body.key.to_owned());
room.send(ReactionEventContent::from(annotation)).await?;
Ok(HttpResponse::Accepted().finish())
}
pub async fn redact_event(
client: MatrixClientExtractor,
path: web::Path<RoomIdInPath>,
event_path: web::Path<EventIdInPath>,
) -> HttpResult {
let Some(room) = client.client.client.get_room(&path.room_id) else {
return Ok(HttpResponse::NotFound().json("Room not found!"));
};
Ok(match room.redact(&event_path.event_id, None, None).await {
Ok(_) => HttpResponse::Accepted().finish(),
Err(e) => {
log::error!("Failed to redact event {}: {e}", event_path.event_id);
HttpResponse::InternalServerError().json(format!("Failed to redact event! {e}"))
}
})
}

View File

@@ -0,0 +1,67 @@
use crate::controllers::HttpResult;
use crate::extractors::matrix_client_extractor::MatrixClientExtractor;
use crate::utils::crypt_utils::sha512;
use actix_web::dev::Payload;
use actix_web::http::header;
use actix_web::{FromRequest, HttpRequest, HttpResponse, web};
use matrix_sdk::media::{MediaFormat, MediaRequestParameters, MediaThumbnailSettings};
use matrix_sdk::ruma::events::room::MediaSource;
use matrix_sdk::ruma::{OwnedMxcUri, UInt};
#[derive(serde::Deserialize)]
struct MediaQuery {
#[serde(default)]
thumbnail: bool,
}
/// Serve a media file
pub async fn serve_media(req: HttpRequest, media: OwnedMxcUri) -> HttpResult {
let query = web::Query::<MediaQuery>::from_request(&req, &mut Payload::None).await?;
let client = MatrixClientExtractor::from_request(&req, &mut Payload::None).await?;
let media = client
.client
.client
.media()
.get_media_content(
&MediaRequestParameters {
source: MediaSource::Plain(media),
format: match query.thumbnail {
true => MediaFormat::Thumbnail(MediaThumbnailSettings::new(
UInt::new(100).unwrap(),
UInt::new(100).unwrap(),
)),
false => MediaFormat::File,
},
},
true,
)
.await?;
let digest = sha512(&media);
let mime_type = infer::get(&media).map(|x| x.mime_type());
// Check if the browser already knows the etag
if let Some(c) = req.headers().get(header::IF_NONE_MATCH)
&& c.to_str().unwrap_or("") == digest
{
return Ok(HttpResponse::NotModified().finish());
}
Ok(HttpResponse::Ok()
.content_type(mime_type.unwrap_or("application/octet-stream"))
.insert_header(("etag", digest))
.insert_header(("cache-control", "max-age=360000"))
.body(media))
}
#[derive(serde::Deserialize)]
pub struct MediaMXCInPath {
mxc: OwnedMxcUri,
}
/// Save media resource handler
pub async fn serve_media_res(req: HttpRequest, media: web::Path<MediaMXCInPath>) -> HttpResult {
serve_media(req, media.into_inner().mxc).await
}

View File

@@ -0,0 +1,67 @@
use crate::controllers::HttpResult;
use crate::extractors::matrix_client_extractor::MatrixClientExtractor;
use actix_web::{HttpResponse, web};
use futures_util::{StreamExt, stream};
use matrix_sdk::ruma::api::client::profile::{AvatarUrl, DisplayName, get_profile};
use matrix_sdk::ruma::{OwnedMxcUri, OwnedUserId};
#[derive(serde::Deserialize)]
pub struct UserIDInPath {
user_id: OwnedUserId,
}
#[derive(serde::Serialize)]
struct ProfileResponse {
user_id: OwnedUserId,
display_name: Option<String>,
avatar: Option<OwnedMxcUri>,
}
impl ProfileResponse {
pub fn from(user_id: OwnedUserId, r: get_profile::v3::Response) -> anyhow::Result<Self> {
Ok(Self {
user_id,
display_name: r.get_static::<DisplayName>()?,
avatar: r.get_static::<AvatarUrl>()?,
})
}
}
/// Get user profile
pub async fn get_profile(
client: MatrixClientExtractor,
path: web::Path<UserIDInPath>,
) -> HttpResult {
let profile = client
.client
.client
.account()
.fetch_user_profile_of(&path.user_id)
.await?;
Ok(HttpResponse::Ok().json(ProfileResponse::from(path.user_id.clone(), profile)?))
}
/// Get multiple users profiles
pub async fn get_multiple(client: MatrixClientExtractor) -> HttpResult {
let users = client.auth.decode_json_body::<Vec<OwnedUserId>>()?;
let list = stream::iter(users)
.then(async |user_id| {
client
.client
.client
.account()
.fetch_user_profile_of(&user_id)
.await
.map(|r| ProfileResponse::from(user_id, r))
})
.collect::<Vec<_>>()
.await
.into_iter()
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.collect::<Result<Vec<_>, _>>()?;
Ok(HttpResponse::Ok().json(list))
}

View File

@@ -0,0 +1,115 @@
use crate::controllers::HttpResult;
use crate::controllers::matrix::matrix_event_controller::{APIEvent, get_events};
use crate::controllers::matrix::matrix_media_controller;
use crate::extractors::matrix_client_extractor::MatrixClientExtractor;
use actix_web::{HttpRequest, HttpResponse, web};
use futures_util::{StreamExt, stream};
use matrix_sdk::room::ParentSpace;
use matrix_sdk::ruma::{OwnedMxcUri, OwnedRoomId, OwnedUserId};
use matrix_sdk::{Room, RoomMemberships};
#[derive(serde::Serialize)]
pub struct APIRoomInfo {
id: OwnedRoomId,
name: Option<String>,
members: Vec<OwnedUserId>,
avatar: Option<OwnedMxcUri>,
is_space: bool,
parents: Vec<OwnedRoomId>,
number_unread_messages: u64,
latest_event: Option<APIEvent>,
}
impl APIRoomInfo {
async fn from_room(r: &Room) -> anyhow::Result<Self> {
// Get parent spaces
let parent_spaces = r
.parent_spaces()
.await?
.collect::<Vec<_>>()
.await
.into_iter()
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.filter_map(|d| match d {
ParentSpace::Reciprocal(r) | ParentSpace::WithPowerlevel(r) => {
Some(r.room_id().to_owned())
}
_ => None,
})
.collect::<Vec<_>>();
Ok(Self {
id: r.room_id().to_owned(),
name: r.name(),
members: r
.members(RoomMemberships::ACTIVE)
.await?
.into_iter()
.map(|r| r.user_id().to_owned())
.collect::<Vec<_>>(),
avatar: r.avatar_url(),
is_space: r.is_space(),
parents: parent_spaces,
number_unread_messages: r.unread_notification_counts().notification_count,
latest_event: get_events(r, 1, None).await?.events.into_iter().next(),
})
}
}
/// Get the list of joined rooms of the user
pub async fn joined_rooms(client: MatrixClientExtractor) -> HttpResult {
let list = stream::iter(client.client.client.joined_rooms())
.then(async |room| APIRoomInfo::from_room(&room).await)
.collect::<Vec<_>>()
.await
.into_iter()
.collect::<Result<Vec<_>, _>>()?;
Ok(HttpResponse::Ok().json(list))
}
/// Get joined spaces rooms of user
pub async fn get_joined_spaces(client: MatrixClientExtractor) -> HttpResult {
let list = stream::iter(client.client.client.joined_space_rooms())
.then(async |room| APIRoomInfo::from_room(&room).await)
.collect::<Vec<_>>()
.await
.into_iter()
.collect::<Result<Vec<_>, _>>()?;
Ok(HttpResponse::Ok().json(list))
}
#[derive(serde::Deserialize)]
pub struct RoomIdInPath {
pub(crate) room_id: OwnedRoomId,
}
/// Get the list of joined rooms of the user
pub async fn single_room_info(
client: MatrixClientExtractor,
path: web::Path<RoomIdInPath>,
) -> HttpResult {
Ok(match client.client.client.get_room(&path.room_id) {
None => HttpResponse::NotFound().json("Room not found"),
Some(r) => HttpResponse::Ok().json(APIRoomInfo::from_room(&r).await?),
})
}
/// Get room avatar
pub async fn room_avatar(
req: HttpRequest,
client: MatrixClientExtractor,
path: web::Path<RoomIdInPath>,
) -> HttpResult {
let Some(room) = client.client.client.get_room(&path.room_id) else {
return Ok(HttpResponse::NotFound().json("Room not found"));
};
let Some(uri) = room.avatar_url() else {
return Ok(HttpResponse::NotFound().json("Room has no avatar"));
};
matrix_media_controller::serve_media(req, uri).await
}

View File

@@ -0,0 +1,4 @@
pub mod matrix_event_controller;
pub mod matrix_media_controller;
pub mod matrix_profile_controller;
pub mod matrix_room_controller;

View File

@@ -1,6 +1,10 @@
use crate::controllers::HttpResult; use crate::controllers::HttpResult;
use crate::extractors::auth_extractor::AuthExtractor;
use crate::extractors::matrix_client_extractor::MatrixClientExtractor; use crate::extractors::matrix_client_extractor::MatrixClientExtractor;
use actix_web::HttpResponse; use crate::matrix_connection::matrix_client::FinishMatrixAuth;
use crate::matrix_connection::matrix_manager::MatrixManagerMsg;
use actix_web::{HttpResponse, web};
use ractor::ActorRef;
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
struct StartAuthResponse { struct StartAuthResponse {
@@ -12,3 +16,44 @@ pub async fn start_auth(client: MatrixClientExtractor) -> HttpResult {
let url = client.client.initiate_login().await?.to_string(); let url = client.client.initiate_login().await?.to_string();
Ok(HttpResponse::Ok().json(StartAuthResponse { url })) Ok(HttpResponse::Ok().json(StartAuthResponse { url }))
} }
/// Finish user authentication on Matrix server
pub async fn finish_auth(client: MatrixClientExtractor) -> HttpResult {
match client
.client
.finish_login(client.auth.decode_json_body::<FinishMatrixAuth>()?)
.await
{
Ok(_) => Ok(HttpResponse::Accepted().finish()),
Err(e) => {
log::error!("Failed to finish Matrix authentication: {e}");
Err(e.into())
}
}
}
/// Logout user from Matrix server
pub async fn logout(
auth: AuthExtractor,
manager: web::Data<ActorRef<MatrixManagerMsg>>,
) -> HttpResult {
manager
.cast(MatrixManagerMsg::DisconnectClient(auth.user.email))
.expect("Failed to communicate with matrix manager!");
Ok(HttpResponse::Ok().finish())
}
#[derive(serde::Deserialize)]
struct SetRecoveryKeyRequest {
key: String,
}
/// Set recovery key of user
pub async fn set_recovery_key(client: MatrixClientExtractor) -> HttpResult {
let key = client.auth.decode_json_body::<SetRecoveryKeyRequest>()?.key;
client.client.set_recovery_key(&key).await?;
Ok(HttpResponse::Accepted().finish())
}

View File

@@ -0,0 +1,59 @@
use crate::controllers::HttpResult;
use crate::extractors::matrix_client_extractor::MatrixClientExtractor;
use crate::matrix_connection::matrix_manager::MatrixManagerMsg;
use actix_web::{HttpResponse, web};
use ractor::ActorRef;
/// Start sync thread
pub async fn start_sync(
client: MatrixClientExtractor,
manager: web::Data<ActorRef<MatrixManagerMsg>>,
) -> HttpResult {
match ractor::cast!(
manager,
MatrixManagerMsg::StartSyncThread(client.auth.user.email.clone())
) {
Ok(_) => Ok(HttpResponse::Accepted().finish()),
Err(e) => {
log::error!("Failed to start sync: {e}");
Ok(HttpResponse::InternalServerError().finish())
}
}
}
/// Stop sync thread
pub async fn stop_sync(
client: MatrixClientExtractor,
manager: web::Data<ActorRef<MatrixManagerMsg>>,
) -> HttpResult {
match ractor::cast!(
manager,
MatrixManagerMsg::StopSyncThread(client.auth.user.email.clone())
) {
Ok(_) => Ok(HttpResponse::Accepted().finish()),
Err(e) => {
log::error!("Failed to stop sync thread: {e}");
Ok(HttpResponse::InternalServerError().finish())
}
}
}
#[derive(serde::Serialize)]
struct GetSyncStatusResponse {
started: bool,
}
/// Get sync thread status
pub async fn status(
client: MatrixClientExtractor,
manager: web::Data<ActorRef<MatrixManagerMsg>>,
) -> HttpResult {
let started = ractor::call!(
manager.as_ref(),
MatrixManagerMsg::SyncThreadGetStatus,
client.auth.user.email
)
.expect("RPC to Matrix Manager failed");
Ok(HttpResponse::Ok().json(GetSyncStatusResponse { started }))
}

View File

@@ -3,8 +3,12 @@ use actix_web::{HttpResponse, ResponseError};
use std::error::Error; use std::error::Error;
pub mod auth_controller; pub mod auth_controller;
pub mod matrix;
pub mod matrix_link_controller; pub mod matrix_link_controller;
pub mod matrix_sync_thread_controller;
pub mod server_controller; pub mod server_controller;
pub mod tokens_controller;
pub mod ws_controller;
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
pub enum HttpFailure { pub enum HttpFailure {
@@ -16,6 +20,10 @@ pub enum HttpFailure {
OpenID(Box<dyn Error>), OpenID(Box<dyn Error>),
#[error("an unspecified internal error occurred: {0}")] #[error("an unspecified internal error occurred: {0}")]
InternalError(#[from] anyhow::Error), InternalError(#[from] anyhow::Error),
#[error("Actix web error: {0}")]
ActixError(#[from] actix_web::Error),
#[error("Matrix error: {0}")]
MatrixError(#[from] matrix_sdk::Error),
} }
impl ResponseError for HttpFailure { impl ResponseError for HttpFailure {
@@ -28,7 +36,9 @@ impl ResponseError for HttpFailure {
} }
fn error_response(&self) -> HttpResponse { fn error_response(&self) -> HttpResponse {
HttpResponse::build(self.status_code()).body(self.to_string()) HttpResponse::build(self.status_code())
.content_type("text/plain")
.body(self.to_string())
} }
} }

View File

@@ -0,0 +1,53 @@
use crate::broadcast_messages::BroadcastSender;
use crate::controllers::HttpResult;
use crate::extractors::auth_extractor::{AuthExtractor, AuthenticatedMethod};
use crate::users::{APIToken, APITokenID, BaseAPIToken};
use actix_web::{HttpResponse, web};
/// Create a new token
pub async fn create(auth: AuthExtractor) -> HttpResult {
if matches!(auth.method, AuthenticatedMethod::Token(_)) {
return Ok(HttpResponse::Forbidden()
.json("It is not allowed to create a token using another token!"));
}
let base = auth.decode_json_body::<BaseAPIToken>()?;
if let Some(err) = base.check() {
return Ok(HttpResponse::BadRequest().json(err));
}
let token = APIToken::create(&auth.as_ref().email, base).await?;
Ok(HttpResponse::Ok().json(token))
}
/// Get the list of tokens of current user
pub async fn get_list(auth: AuthExtractor) -> HttpResult {
Ok(HttpResponse::Ok().json(
APIToken::list_user(&auth.as_ref().email)
.await?
.into_iter()
.map(|mut t| {
t.secret = String::new();
t
})
.collect::<Vec<_>>(),
))
}
#[derive(serde::Deserialize)]
pub struct TokenIDInPath {
id: APITokenID,
}
/// Delete an API access token
pub async fn delete(
auth: AuthExtractor,
path: web::Path<TokenIDInPath>,
tx: web::Data<BroadcastSender>,
) -> HttpResult {
let token = APIToken::load(&auth.user.email, &path.id).await?;
token.delete(&auth.user.email, &tx).await?;
Ok(HttpResponse::Accepted().finish())
}

View File

@@ -0,0 +1,252 @@
use crate::broadcast_messages::BroadcastMessage;
use crate::constants;
use crate::controllers::HttpResult;
use crate::extractors::auth_extractor::{AuthExtractor, AuthenticatedMethod};
use crate::extractors::matrix_client_extractor::MatrixClientExtractor;
use crate::matrix_connection::matrix_client::MatrixClient;
use crate::matrix_connection::matrix_manager::MatrixManagerMsg;
use crate::users::UserEmail;
use actix_web::dev::Payload;
use actix_web::{FromRequest, HttpRequest, HttpResponse, web};
use actix_ws::Message;
use futures_util::StreamExt;
use matrix_sdk::ruma::events::reaction::ReactionEventContent;
use matrix_sdk::ruma::events::room::message::RoomMessageEventContent;
use matrix_sdk::ruma::events::room::redaction::RoomRedactionEventContent;
use matrix_sdk::ruma::{MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedRoomId, OwnedUserId};
use ractor::ActorRef;
use std::time::Instant;
use tokio::sync::broadcast;
use tokio::sync::broadcast::Receiver;
use tokio::time::interval;
#[derive(Debug, serde::Serialize)]
pub struct WsRoomEvent<E> {
pub room_id: OwnedRoomId,
pub event_id: OwnedEventId,
pub sender: OwnedUserId,
pub origin_server_ts: MilliSecondsSinceUnixEpoch,
pub data: Box<E>,
}
/// Messages sent to the client
#[derive(Debug, serde::Serialize)]
#[serde(tag = "type")]
pub enum WsMessage {
/// Room message event
RoomMessageEvent(WsRoomEvent<RoomMessageEventContent>),
/// Room reaction event
RoomReactionEvent(WsRoomEvent<ReactionEventContent>),
/// Room reaction event
RoomRedactionEvent(WsRoomEvent<RoomRedactionEventContent>),
}
impl WsMessage {
pub fn from_bx_message(msg: &BroadcastMessage, user: &UserEmail) -> Option<Self> {
match msg {
BroadcastMessage::RoomMessageEvent(evt) if &evt.user == user => {
Some(Self::RoomMessageEvent(WsRoomEvent {
room_id: evt.room.room_id().to_owned(),
event_id: evt.data.event_id.clone(),
sender: evt.data.sender.clone(),
origin_server_ts: evt.data.origin_server_ts,
data: Box::new(evt.data.content.clone()),
}))
}
BroadcastMessage::ReactionEvent(evt) if &evt.user == user => {
Some(Self::RoomReactionEvent(WsRoomEvent {
room_id: evt.room.room_id().to_owned(),
event_id: evt.data.event_id.clone(),
sender: evt.data.sender.clone(),
origin_server_ts: evt.data.origin_server_ts,
data: Box::new(evt.data.content.clone()),
}))
}
BroadcastMessage::RoomRedactionEvent(evt) if &evt.user == user => {
Some(Self::RoomRedactionEvent(WsRoomEvent {
room_id: evt.room.room_id().to_owned(),
event_id: evt.data.event_id.clone(),
sender: evt.data.sender.clone(),
origin_server_ts: evt.data.origin_server_ts,
data: Box::new(evt.data.content.clone()),
}))
}
_ => None,
}
}
}
/// Main WS route
pub async fn ws(
req: HttpRequest,
stream: web::Payload,
tx: web::Data<broadcast::Sender<BroadcastMessage>>,
manager: web::Data<ActorRef<MatrixManagerMsg>>,
) -> HttpResult {
// Forcefully ignore request payload by manually extracting authentication information
let client = MatrixClientExtractor::from_request(&req, &mut Payload::None).await?;
// Check if Matrix link has been established first
if !client.client.is_client_connected() {
return Ok(HttpResponse::ExpectationFailed().json("Matrix link not established yet!"));
}
// Ensure sync thread is started
ractor::cast!(
manager,
MatrixManagerMsg::StartSyncThread(client.auth.user.email.clone())
)
.expect("Failed to start sync thread prior to running WebSocket!");
let rx = tx.subscribe();
let (res, session, msg_stream) = actix_ws::handle(&req, stream)?;
// spawn websocket handler (and don't await it) so that the response is returned immediately
actix_web::rt::spawn(ws_handler(
session,
msg_stream,
client.auth,
client.client,
rx,
));
Ok(res)
}
pub async fn ws_handler(
mut session: actix_ws::Session,
mut msg_stream: actix_ws::MessageStream,
auth: AuthExtractor,
client: MatrixClient,
mut rx: Receiver<BroadcastMessage>,
) {
log::info!(
"WS connected for user {:?} / auth method={}",
client.email,
auth.method.light_str()
);
let mut last_heartbeat = Instant::now();
let mut interval = interval(constants::WS_HEARTBEAT_INTERVAL);
let reason = loop {
// waits for either `msg_stream` to receive a message from the client, the broadcast channel
// to send a message, or the heartbeat interval timer to tick, yielding the value of
// whichever one is ready first
tokio::select! {
ws_msg = rx.recv() => {
let msg = match ws_msg {
Ok(msg) => msg,
Err(broadcast::error::RecvError::Closed) => break None,
Err(broadcast::error::RecvError::Lagged(_)) => continue,
};
match (&msg, WsMessage::from_bx_message(&msg, &auth.user.email)) {
(BroadcastMessage::APITokenDeleted(t), _) => {
match &auth.method{
AuthenticatedMethod::Token(tok) if tok.id == t.id => {
log::info!(
"closing WS session of user {:?} as associated token was deleted {:?}",
client.email,
t.base.name
);
break None;
}
_=>{}
}
},
(BroadcastMessage::UserDisconnectedFromMatrix(mail), _) if mail == &auth.user.email => {
log::info!(
"closing WS session of user {mail:?} as user was disconnected from Matrix"
);
break None;
}
(_, Some(message)) => {
// Send the message to the websocket
if let Ok(msg) = serde_json::to_string(&message)
&& let Err(e) = session.text(msg).await {
log::error!("Failed to send SyncEvent: {e}");
}
}
_ => {}
};
}
// heartbeat interval ticked
_tick = interval.tick() => {
// if no heartbeat ping/pong received recently, close the connection
if Instant::now().duration_since(last_heartbeat) > constants::WS_CLIENT_TIMEOUT {
log::info!(
"client has not sent heartbeat in over {:?}; disconnecting",constants::WS_CLIENT_TIMEOUT
);
break None;
}
// send heartbeat ping
let _ = session.ping(b"").await;
},
// Websocket messages
msg = msg_stream.next() => {
let msg = match msg {
// received message from WebSocket client
Some(Ok(msg)) => msg,
// client WebSocket stream error
Some(Err(err)) => {
log::error!("{err}");
break None;
}
// client WebSocket stream ended
None => break None
};
log::debug!("msg: {msg:?}");
match msg {
Message::Text(s) => {
log::info!("Text message from WS: {s}");
}
Message::Binary(_) => {
// drop client's binary messages
}
Message::Close(reason) => {
break reason;
}
Message::Ping(bytes) => {
last_heartbeat = Instant::now();
let _ = session.pong(&bytes).await;
}
Message::Pong(_) => {
last_heartbeat = Instant::now();
}
Message::Continuation(_) => {
log::warn!("no support for continuation frames");
}
// no-op; ignore
Message::Nop => {}
};
}
}
};
// attempt to close connection gracefully
let _ = session.close(reason).await;
log::info!("WS disconnected for user {:?}", client.email);
}

View File

@@ -11,6 +11,8 @@ use anyhow::Context;
use bytes::Bytes; use bytes::Bytes;
use jwt_simple::common::VerificationOptions; use jwt_simple::common::VerificationOptions;
use jwt_simple::prelude::{Duration, HS256Key, MACLike}; use jwt_simple::prelude::{Duration, HS256Key, MACLike};
use jwt_simple::reexports::serde_json;
use serde::de::DeserializeOwned;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::fmt::Display; use std::fmt::Display;
use std::net::IpAddr; use std::net::IpAddr;
@@ -26,12 +28,38 @@ pub enum AuthenticatedMethod {
Token(APIToken), Token(APIToken),
} }
impl AuthenticatedMethod {
pub fn light_str(&self) -> String {
match self {
AuthenticatedMethod::Cookie => "Cookie".to_string(),
AuthenticatedMethod::Dev => "DevAuthentication".to_string(),
AuthenticatedMethod::Token(t) => format!("Token({:?} - {})", t.id, t.base.name),
}
}
}
pub struct AuthExtractor { pub struct AuthExtractor {
pub user: User, pub user: User,
pub method: AuthenticatedMethod, pub method: AuthenticatedMethod,
pub payload: Option<Vec<u8>>, pub payload: Option<Vec<u8>>,
} }
impl AsRef<User> for AuthExtractor {
fn as_ref(&self) -> &User {
&self.user
}
}
impl AuthExtractor {
pub fn decode_json_body<E: DeserializeOwned + Send>(&self) -> anyhow::Result<E> {
let payload = self
.payload
.as_ref()
.context("Failed to decode request as json: missing payload!")?;
Ok(serde_json::from_slice(payload)?)
}
}
#[derive(Debug, Eq, PartialEq)] #[derive(Debug, Eq, PartialEq)]
pub struct MatrixJWTKID { pub struct MatrixJWTKID {
pub user_email: UserEmail, pub user_email: UserEmail,
@@ -144,8 +172,9 @@ impl AuthExtractor {
} }
// Check IP restriction // Check IP restriction
if let Some(net) = token.network if let Some(nets) = &token.base.networks
&& !net.contains(&remote_ip) && !nets.is_empty()
&& !nets.iter().any(|n| n.contains(&remote_ip))
{ {
log::error!( log::error!(
"Trying to use token {:?} from unauthorized IP address: {remote_ip:?}", "Trying to use token {:?} from unauthorized IP address: {remote_ip:?}",
@@ -157,7 +186,7 @@ impl AuthExtractor {
} }
// Check for write access // Check for write access
if token.read_only && !req.method().is_safe() { if token.base.read_only && !req.method().is_safe() {
return Err(actix_web::error::ErrorBadRequest( return Err(actix_web::error::ErrorBadRequest(
"Read only token cannot perform write operations!", "Read only token cannot perform write operations!",
)); ));

View File

@@ -1,6 +1,7 @@
use crate::extractors::auth_extractor::AuthExtractor; use crate::extractors::auth_extractor::AuthExtractor;
use crate::matrix_connection::matrix_client::MatrixClient; use crate::matrix_connection::matrix_client::MatrixClient;
use crate::matrix_connection::matrix_manager::MatrixManagerMsg; use crate::matrix_connection::matrix_manager::MatrixManagerMsg;
use crate::users::ExtendedUserInfo;
use actix_web::dev::Payload; use actix_web::dev::Payload;
use actix_web::{FromRequest, HttpRequest, web}; use actix_web::{FromRequest, HttpRequest, web};
use ractor::ActorRef; use ractor::ActorRef;
@@ -10,6 +11,18 @@ pub struct MatrixClientExtractor {
pub client: MatrixClient, pub client: MatrixClient,
} }
impl MatrixClientExtractor {
pub async fn to_extended_user_info(&self) -> anyhow::Result<ExtendedUserInfo> {
Ok(ExtendedUserInfo {
user: self.auth.user.clone(),
matrix_account_connected: self.client.is_client_connected(),
matrix_user_id: self.client.user_id().map(|id| id.to_string()),
matrix_device_id: self.client.device_id().map(|id| id.to_string()),
matrix_recovery_state: self.client.recovery_state(),
})
}
}
impl FromRequest for MatrixClientExtractor { impl FromRequest for MatrixClientExtractor {
type Error = actix_web::Error; type Error = actix_web::Error;
type Future = futures_util::future::LocalBoxFuture<'static, Result<Self, Self::Error>>; type Future = futures_util::future::LocalBoxFuture<'static, Result<Self, Self::Error>>;
@@ -27,9 +40,13 @@ impl FromRequest for MatrixClientExtractor {
matrix_manager_actor, matrix_manager_actor,
MatrixManagerMsg::GetClient, MatrixManagerMsg::GetClient,
auth.user.email.clone() auth.user.email.clone()
) );
.expect("Failed to query manager actor!")
.expect("Failed to get client!"); let client = match client {
Ok(Ok(client)) => client,
Ok(Err(err)) => panic!("Failed to get client! {err:?}"),
Err(err) => panic!("Failed to query manager actor! {err:#?}"),
};
Ok(Self { auth, client }) Ok(Self { auth, client })
}) })

View File

@@ -1,4 +1,5 @@
pub mod app_config; pub mod app_config;
pub mod broadcast_messages;
pub mod constants; pub mod constants;
pub mod controllers; pub mod controllers;
pub mod extractors; pub mod extractors;

View File

@@ -7,8 +7,16 @@ use actix_web::cookie::Key;
use actix_web::middleware::Logger; use actix_web::middleware::Logger;
use actix_web::{App, HttpServer, web}; use actix_web::{App, HttpServer, web};
use matrixgw_backend::app_config::AppConfig; use matrixgw_backend::app_config::AppConfig;
use matrixgw_backend::broadcast_messages::BroadcastMessage;
use matrixgw_backend::constants; use matrixgw_backend::constants;
use matrixgw_backend::controllers::{auth_controller, matrix_link_controller, server_controller}; use matrixgw_backend::controllers::matrix::{
matrix_event_controller, matrix_media_controller, matrix_profile_controller,
matrix_room_controller,
};
use matrixgw_backend::controllers::{
auth_controller, matrix_link_controller, matrix_sync_thread_controller, server_controller,
tokens_controller, ws_controller,
};
use matrixgw_backend::matrix_connection::matrix_manager::MatrixManagerActor; use matrixgw_backend::matrix_connection::matrix_manager::MatrixManagerActor;
use matrixgw_backend::users::User; use matrixgw_backend::users::User;
use ractor::Actor; use ractor::Actor;
@@ -24,6 +32,8 @@ async fn main() -> std::io::Result<()> {
.await .await
.expect("Failed to connect to Redis!"); .expect("Failed to connect to Redis!");
let (ws_tx, _) = tokio::sync::broadcast::channel::<BroadcastMessage>(16);
// Auto create default account, if requested // Auto create default account, if requested
if let Some(mail) = &AppConfig::get().unsecure_auto_login_email() { if let Some(mail) = &AppConfig::get().unsecure_auto_login_email() {
User::create_or_update_user(mail, "Anonymous") User::create_or_update_user(mail, "Anonymous")
@@ -35,7 +45,7 @@ async fn main() -> std::io::Result<()> {
let (manager_actor, manager_actor_handle) = Actor::spawn( let (manager_actor, manager_actor_handle) = Actor::spawn(
Some("matrix-clients-manager".to_string()), Some("matrix-clients-manager".to_string()),
MatrixManagerActor, MatrixManagerActor,
(), ws_tx.clone(),
) )
.await .await
.expect("Failed to start Matrix manager actor!"); .expect("Failed to start Matrix manager actor!");
@@ -55,7 +65,7 @@ async fn main() -> std::io::Result<()> {
let cors = Cors::default() let cors = Cors::default()
.allowed_origin(&AppConfig::get().website_origin) .allowed_origin(&AppConfig::get().website_origin)
.allowed_methods(vec!["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]) .allowed_methods(["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"])
.allowed_header(constants::API_AUTH_HEADER) .allowed_header(constants::API_AUTH_HEADER)
.allow_any_header() .allow_any_header()
.supports_credentials() .supports_credentials()
@@ -69,6 +79,7 @@ async fn main() -> std::io::Result<()> {
.app_data(web::Data::new(RemoteIPConfig { .app_data(web::Data::new(RemoteIPConfig {
proxy: AppConfig::get().proxy_ip.clone(), proxy: AppConfig::get().proxy_ip.clone(),
})) }))
.app_data(web::Data::new(ws_tx.clone()))
// Server controller // Server controller
.route("/robots.txt", web::get().to(server_controller::robots_txt)) .route("/robots.txt", web::get().to(server_controller::robots_txt))
.route( .route(
@@ -94,6 +105,91 @@ async fn main() -> std::io::Result<()> {
"/api/matrix_link/start_auth", "/api/matrix_link/start_auth",
web::post().to(matrix_link_controller::start_auth), web::post().to(matrix_link_controller::start_auth),
) )
.route(
"/api/matrix_link/finish_auth",
web::post().to(matrix_link_controller::finish_auth),
)
.route(
"/api/matrix_link/logout",
web::post().to(matrix_link_controller::logout),
)
.route(
"/api/matrix_link/set_recovery_key",
web::post().to(matrix_link_controller::set_recovery_key),
)
// API Tokens controller
.route("/api/token", web::post().to(tokens_controller::create))
.route("/api/tokens", web::get().to(tokens_controller::get_list))
.route(
"/api/token/{id}",
web::delete().to(tokens_controller::delete),
)
// Matrix synchronization controller
.route(
"/api/matrix_sync/start",
web::post().to(matrix_sync_thread_controller::start_sync),
)
.route(
"/api/matrix_sync/stop",
web::post().to(matrix_sync_thread_controller::stop_sync),
)
.route(
"/api/matrix_sync/status",
web::get().to(matrix_sync_thread_controller::status),
)
.service(web::resource("/api/ws").route(web::get().to(ws_controller::ws)))
// Matrix room controller
.route(
"/api/matrix/room/joined",
web::get().to(matrix_room_controller::joined_rooms),
)
.route(
"/api/matrix/room/joined_spaces",
web::get().to(matrix_room_controller::get_joined_spaces),
)
.route(
"/api/matrix/room/{room_id}",
web::get().to(matrix_room_controller::single_room_info),
)
.route(
"/api/matrix/room/{room_id}/avatar",
web::get().to(matrix_room_controller::room_avatar),
)
// Matrix profile controller
.route(
"/api/matrix/profile/{user_id}",
web::get().to(matrix_profile_controller::get_profile),
)
.route(
"/api/matrix/profile/get_multiple",
web::post().to(matrix_profile_controller::get_multiple),
)
// Matrix events controller
.route(
"/api/matrix/room/{room_id}/events",
web::get().to(matrix_event_controller::get_for_room),
)
.route(
"/api/matrix/room/{room_id}/send_text_message",
web::post().to(matrix_event_controller::send_text_message),
)
.route(
"/api/matrix/room/{room_id}/event/{event_id}/set_text_content",
web::post().to(matrix_event_controller::set_text_content),
)
.route(
"/api/matrix/room/{room_id}/event/{event_id}/react",
web::post().to(matrix_event_controller::react_to_event),
)
.route(
"/api/matrix/room/{room_id}/event/{event_id}",
web::delete().to(matrix_event_controller::redact_event),
)
// Matrix media controller
.route(
"/api/matrix/media/{mxc}",
web::get().to(matrix_media_controller::serve_media_res),
)
}) })
.workers(4) .workers(4)
.bind(&AppConfig::get().listen_address)? .bind(&AppConfig::get().listen_address)?

View File

@@ -1,15 +1,50 @@
use crate::app_config::AppConfig; use crate::app_config::AppConfig;
use crate::matrix_connection::matrix_manager::MatrixManagerMsg;
use crate::users::UserEmail; use crate::users::UserEmail;
use crate::utils::rand_utils::rand_string; use crate::utils::rand_utils::rand_string;
use matrix_sdk::authentication::oauth::OAuthError; use anyhow::Context;
use futures_util::Stream;
use matrix_sdk::authentication::oauth::error::OAuthDiscoveryError; use matrix_sdk::authentication::oauth::error::OAuthDiscoveryError;
use matrix_sdk::authentication::oauth::{
ClientId, OAuthError, OAuthSession, UrlOrQuery, UserSession,
};
use matrix_sdk::config::SyncSettings;
use matrix_sdk::encryption::recovery::RecoveryState;
use matrix_sdk::event_handler::{EventHandler, EventHandlerHandle, SyncEvent};
use matrix_sdk::ruma::presence::PresenceState;
use matrix_sdk::ruma::serde::Raw; use matrix_sdk::ruma::serde::Raw;
use matrix_sdk::{Client, ClientBuildError}; use matrix_sdk::ruma::{DeviceId, UserId};
use matrix_sdk::sync::SyncResponse;
use matrix_sdk::{Client, ClientBuildError, SendOutsideWasm};
use ractor::ActorRef;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use std::pin::Pin;
use url::Url; use url::Url;
/// The full session to persist.
#[derive(Debug, Serialize, Deserialize, Clone)]
struct StoredSession {
/// The OAuth 2.0 user session.
user_session: UserSession,
/// The OAuth 2.0 client ID.
client_id: ClientId,
}
#[derive(Debug, Serialize, Deserialize, Copy, Clone)]
pub enum EncryptionRecoveryState {
Unknown,
Enabled,
Disabled,
Incomplete,
}
/// Matrix Gateway session errors /// Matrix Gateway session errors
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
enum MatrixClientError { enum MatrixClientError {
#[error("Failed to destroy previous client data! {0}")]
DestroyPreviousData(Box<MatrixClientError>),
#[error("Failed to create Matrix database storage directory! {0}")] #[error("Failed to create Matrix database storage directory! {0}")]
CreateMatrixDbDir(std::io::Error), CreateMatrixDbDir(std::io::Error),
#[error("Failed to create database passphrase! {0}")] #[error("Failed to create database passphrase! {0}")]
@@ -18,27 +53,61 @@ enum MatrixClientError {
ReadDbPassphrase(std::io::Error), ReadDbPassphrase(std::io::Error),
#[error("Failed to build Matrix client! {0}")] #[error("Failed to build Matrix client! {0}")]
BuildMatrixClient(ClientBuildError), BuildMatrixClient(ClientBuildError),
#[error("Failed to clear Matrix session file! {0}")]
ClearMatrixSessionFile(std::io::Error),
#[error("Failed to clear Matrix database storage directory! {0}")] #[error("Failed to clear Matrix database storage directory! {0}")]
ClearMatrixDbDir(std::io::Error), ClearMatrixDbDir(std::io::Error),
#[error("Failed to remove database passphrase! {0}")] #[error("Failed to remove database passphrase! {0}")]
ClearDbPassphrase(std::io::Error), ClearDbPassphrase(std::io::Error),
#[error("Failed to fetch server metadata! {0}")] #[error("Failed to fetch server metadata! {0}")]
FetchServerMetadata(OAuthDiscoveryError), FetchServerMetadata(OAuthDiscoveryError),
#[error("Failed to load stored session! {0}")]
LoadStoredSession(std::io::Error),
#[error("Failed to decode stored session! {0}")]
DecodeStoredSession(serde_json::Error),
#[error("Failed to restore stored session! {0}")]
RestoreSession(matrix_sdk::Error),
#[error("Failed to parse auth redirect URL! {0}")] #[error("Failed to parse auth redirect URL! {0}")]
ParseAuthRedirectURL(url::ParseError), ParseAuthRedirectURL(url::ParseError),
#[error("Failed to build auth request! {0}")] #[error("Failed to build auth request! {0}")]
BuildAuthRequest(OAuthError), BuildAuthRequest(OAuthError),
#[error("Failed to finalize authentication! {0}")]
FinishLogin(matrix_sdk::Error),
#[error("Failed to write session file! {0}")]
WriteSessionFile(std::io::Error),
#[error("Failed to rename device! {0}")]
RenameDevice(matrix_sdk::HttpError),
#[error("Failed to set recovery key! {0}")]
SetRecoveryKey(matrix_sdk::encryption::recovery::RecoveryError),
}
#[derive(serde::Deserialize)]
pub struct FinishMatrixAuth {
code: String,
state: String,
} }
#[derive(Clone)] #[derive(Clone)]
pub struct MatrixClient { pub struct MatrixClient {
manager: ActorRef<MatrixManagerMsg>,
pub email: UserEmail, pub email: UserEmail,
pub client: Client, pub client: Client,
} }
impl MatrixClient { impl MatrixClient {
/// Start to build Matrix client to initiate user authentication /// Start to build Matrix client to initiate user authentication
pub async fn build_client(email: &UserEmail) -> anyhow::Result<Self> { pub async fn build_client(
manager: ActorRef<MatrixManagerMsg>,
email: &UserEmail,
) -> anyhow::Result<Self> {
// Check if we are restoring a previous state
let session_file_path = AppConfig::get().user_matrix_session_file_path(email);
let is_restoring = session_file_path.is_file();
if !is_restoring {
Self::destroy_data(email).map_err(MatrixClientError::DestroyPreviousData)?;
}
// Determine Matrix database path
let db_path = AppConfig::get().user_matrix_db_path(email); let db_path = AppConfig::get().user_matrix_db_path(email);
std::fs::create_dir_all(&db_path).map_err(MatrixClientError::CreateMatrixDbDir)?; std::fs::create_dir_all(&db_path).map_err(MatrixClientError::CreateMatrixDbDir)?;
@@ -52,7 +121,7 @@ impl MatrixClient {
.map_err(MatrixClientError::ReadDbPassphrase)?; .map_err(MatrixClientError::ReadDbPassphrase)?;
let client = Client::builder() let client = Client::builder()
.server_name_or_homeserver_url(&AppConfig::get().matrix_homeserver) .homeserver_url(&AppConfig::get().matrix_homeserver)
// Automatically refresh tokens if needed // Automatically refresh tokens if needed
.handle_refresh_tokens() .handle_refresh_tokens()
.sqlite_store(&db_path, Some(&passphrase)) .sqlite_store(&db_path, Some(&passphrase))
@@ -60,38 +129,77 @@ impl MatrixClient {
.await .await
.map_err(MatrixClientError::BuildMatrixClient)?; .map_err(MatrixClientError::BuildMatrixClient)?;
// Check metadata let client = Self {
let server_metadata = client manager,
.oauth()
.server_metadata()
.await
.map_err(MatrixClientError::FetchServerMetadata)?;
log::info!("OAuth2 server issuer: {:?}", server_metadata.issuer);
// TODO : restore client if client already existed
Ok(Self {
email: email.clone(), email: email.clone(),
client, client,
}) };
// Check metadata
if !is_restoring {
let oauth = client.client.oauth();
let server_metadata = oauth
.server_metadata()
.await
.map_err(MatrixClientError::FetchServerMetadata)?;
log::info!("OAuth2 server issuer: {:?}", server_metadata.issuer);
} else {
let session: StoredSession = serde_json::from_str(
std::fs::read_to_string(session_file_path)
.map_err(MatrixClientError::LoadStoredSession)?
.as_str(),
)
.map_err(MatrixClientError::DecodeStoredSession)?;
// Restore session
client
.client
.restore_session(OAuthSession {
client_id: session.client_id,
user: session.user_session,
})
.await
.map_err(MatrixClientError::RestoreSession)?;
// Wait for encryption tasks to complete
client
.client
.encryption()
.wait_for_e2ee_initialization_tasks()
.await;
// Save stored session once
client.save_stored_session().await?;
}
// Automatically save session when token gets refreshed
client.setup_background_session_save().await;
Ok(client)
} }
/// Destroy this Matrix client instance /// Destroy Matrix client related data
pub fn destroy(&self) -> anyhow::Result<()> { fn destroy_data(email: &UserEmail) -> anyhow::Result<(), Box<MatrixClientError>> {
let db_path = AppConfig::get().user_matrix_db_path(&self.email); let session_path = AppConfig::get().user_matrix_session_file_path(email);
if db_path.is_file() { if session_path.is_file() {
std::fs::remove_file(&session_path)
.map_err(MatrixClientError::ClearMatrixSessionFile)?;
}
let db_path = AppConfig::get().user_matrix_db_path(email);
if db_path.is_dir() {
std::fs::remove_dir_all(&db_path).map_err(MatrixClientError::ClearMatrixDbDir)?; std::fs::remove_dir_all(&db_path).map_err(MatrixClientError::ClearMatrixDbDir)?;
} }
let passphrase_path = AppConfig::get().user_matrix_passphrase_path(&self.email); let passphrase_path = AppConfig::get().user_matrix_passphrase_path(email);
if passphrase_path.is_file() { if passphrase_path.is_file() {
std::fs::remove_file(passphrase_path).map_err(MatrixClientError::ClearDbPassphrase)?; std::fs::remove_file(passphrase_path).map_err(MatrixClientError::ClearDbPassphrase)?;
} }
todo!() Ok(())
} }
/// Initiate oauth authentication /// Initiate OAuth authentication
pub async fn initiate_login(&self) -> anyhow::Result<Url> { pub async fn initiate_login(&self) -> anyhow::Result<Url> {
let oauth = self.client.oauth(); let oauth = self.client.oauth();
@@ -112,4 +220,182 @@ impl MatrixClient {
Ok(auth.url) Ok(auth.url)
} }
/// Finish OAuth authentication
pub async fn finish_login(&self, info: FinishMatrixAuth) -> anyhow::Result<()> {
let oauth = self.client.oauth();
oauth
.finish_login(UrlOrQuery::Query(format!(
"state={}&code={}",
info.state, info.code
)))
.await
.map_err(MatrixClientError::FinishLogin)?;
log::info!(
"User successfully authenticated as {}!",
self.client.user_id().unwrap()
);
// Persist session tokens
self.save_stored_session().await?;
// Rename created session to give it a more explicit name
self.client
.rename_device(
self.client
.session_meta()
.context("Missing device ID!")?
.device_id
.as_ref(),
&AppConfig::get().website_origin,
)
.await
.map_err(MatrixClientError::RenameDevice)?;
Ok(())
}
/// Automatically persist session onto disk
pub async fn setup_background_session_save(&self) {
let this = self.clone();
tokio::spawn(async move {
loop {
match this.client.subscribe_to_session_changes().recv().await {
Ok(update) => match update {
matrix_sdk::SessionChange::UnknownToken { soft_logout } => {
log::warn!(
"Received an unknown token error; soft logout? {soft_logout:?}"
);
if let Err(e) = this
.manager
.cast(MatrixManagerMsg::DisconnectClient(this.email))
{
log::warn!("Failed to propagate invalid token error: {e}");
}
break;
}
matrix_sdk::SessionChange::TokensRefreshed => {
// The tokens have been refreshed, persist them to disk.
if let Err(err) = this.save_stored_session().await {
log::error!("Unable to store a session in the background: {err}");
}
}
},
Err(e) => {
log::error!("[!] Session change error: {e}");
log::error!("Session change background service INTERRUPTED!");
return;
}
}
}
});
}
/// Update the session stored on the filesystem.
async fn save_stored_session(&self) -> anyhow::Result<()> {
log::debug!("Save the stored session for {:?}...", self.email);
let full_session = self
.client
.oauth()
.full_session()
.context("A logged in client must have a session")?;
let stored_session = StoredSession {
user_session: full_session.user,
client_id: full_session.client_id,
};
let serialized_session = serde_json::to_string(&stored_session)?;
std::fs::write(
AppConfig::get().user_matrix_session_file_path(&self.email),
serialized_session,
)
.map_err(MatrixClientError::WriteSessionFile)?;
log::debug!("Updating the stored session: done!");
Ok(())
}
/// Check whether a user is currently connected to this client or not
pub fn is_client_connected(&self) -> bool {
self.client.is_active()
}
/// Disconnect user from client
pub async fn disconnect(self) -> anyhow::Result<()> {
if let Err(e) = self.client.logout().await {
log::warn!("Failed to send logout request: {e}");
}
// Destroy user associated data
Self::destroy_data(&self.email)?;
Ok(())
}
/// Get client Matrix device id
pub fn device_id(&self) -> Option<&DeviceId> {
self.client.device_id()
}
/// Get client Matrix user id
pub fn user_id(&self) -> Option<&UserId> {
self.client.user_id()
}
/// Get current encryption keys recovery state
pub fn recovery_state(&self) -> EncryptionRecoveryState {
match self.client.encryption().recovery().state() {
RecoveryState::Unknown => EncryptionRecoveryState::Unknown,
RecoveryState::Enabled => EncryptionRecoveryState::Enabled,
RecoveryState::Disabled => EncryptionRecoveryState::Disabled,
RecoveryState::Incomplete => EncryptionRecoveryState::Incomplete,
}
}
/// Set new encryption key recovery key
pub async fn set_recovery_key(&self, key: &str) -> anyhow::Result<()> {
Ok(self
.client
.encryption()
.recovery()
.recover(key)
.await
.map_err(MatrixClientError::SetRecoveryKey)?)
}
/// Get matrix synchronization settings to use
fn sync_settings() -> SyncSettings {
SyncSettings::default().set_presence(PresenceState::Offline)
}
/// Perform initial synchronization
pub async fn perform_initial_sync(&self) -> anyhow::Result<()> {
self.client.sync_once(Self::sync_settings()).await?;
Ok(())
}
/// Perform routine synchronization
pub async fn sync_stream(
&self,
) -> Pin<Box<impl Stream<Item = matrix_sdk::Result<SyncResponse>>>> {
Box::pin(self.client.sync_stream(Self::sync_settings()).await)
}
/// Add new Matrix event handler
#[must_use]
pub fn add_event_handler<Ev, Ctx, H>(&self, handler: H) -> EventHandlerHandle
where
Ev: SyncEvent + DeserializeOwned + SendOutsideWasm + 'static,
H: EventHandler<Ev, Ctx>,
{
self.client.add_event_handler(handler)
}
/// Remove Matrix event handler
pub fn remove_event_handler(&self, handle: EventHandlerHandle) {
self.client.remove_event_handler(handle)
}
} }

View File

@@ -1,14 +1,23 @@
use crate::broadcast_messages::{BroadcastMessage, BroadcastSender};
use crate::matrix_connection::matrix_client::MatrixClient; use crate::matrix_connection::matrix_client::MatrixClient;
use crate::matrix_connection::sync_thread::{MatrixSyncTaskID, start_sync_thread};
use crate::users::UserEmail; use crate::users::UserEmail;
use ractor::{Actor, ActorProcessingErr, ActorRef, RpcReplyPort}; use ractor::{Actor, ActorProcessingErr, ActorRef, RpcReplyPort};
use std::collections::HashMap; use std::collections::HashMap;
pub struct MatrixManagerState { pub struct MatrixManagerState {
pub broadcast_sender: BroadcastSender,
pub clients: HashMap<UserEmail, MatrixClient>, pub clients: HashMap<UserEmail, MatrixClient>,
pub running_sync_threads: HashMap<UserEmail, MatrixSyncTaskID>,
} }
pub enum MatrixManagerMsg { pub enum MatrixManagerMsg {
GetClient(UserEmail, RpcReplyPort<anyhow::Result<MatrixClient>>), GetClient(UserEmail, RpcReplyPort<anyhow::Result<MatrixClient>>),
DisconnectClient(UserEmail),
StartSyncThread(UserEmail),
StopSyncThread(UserEmail),
SyncThreadGetStatus(UserEmail, RpcReplyPort<bool>),
SyncThreadTerminated(UserEmail, MatrixSyncTaskID),
} }
pub struct MatrixManagerActor; pub struct MatrixManagerActor;
@@ -16,21 +25,32 @@ pub struct MatrixManagerActor;
impl Actor for MatrixManagerActor { impl Actor for MatrixManagerActor {
type Msg = MatrixManagerMsg; type Msg = MatrixManagerMsg;
type State = MatrixManagerState; type State = MatrixManagerState;
type Arguments = (); type Arguments = BroadcastSender;
async fn pre_start( async fn pre_start(
&self, &self,
_myself: ActorRef<Self::Msg>, _myself: ActorRef<Self::Msg>,
_args: Self::Arguments, args: Self::Arguments,
) -> Result<Self::State, ActorProcessingErr> { ) -> Result<Self::State, ActorProcessingErr> {
Ok(MatrixManagerState { Ok(MatrixManagerState {
broadcast_sender: args,
clients: HashMap::new(), clients: HashMap::new(),
running_sync_threads: Default::default(),
}) })
} }
async fn post_stop(
&self,
_myself: ActorRef<Self::Msg>,
_state: &mut Self::State,
) -> Result<(), ActorProcessingErr> {
log::error!("[!] [!] Matrix Manager Actor stopped!");
Ok(())
}
async fn handle( async fn handle(
&self, &self,
_myself: ActorRef<Self::Msg>, myself: ActorRef<Self::Msg>,
message: Self::Msg, message: Self::Msg,
state: &mut Self::State, state: &mut Self::State,
) -> Result<(), ActorProcessingErr> { ) -> Result<(), ActorProcessingErr> {
@@ -41,7 +61,7 @@ impl Actor for MatrixManagerActor {
None => { None => {
// Generate client if required // Generate client if required
log::info!("Building new client for {:?}", &email); log::info!("Building new client for {:?}", &email);
match MatrixClient::build_client(&email).await { match MatrixClient::build_client(myself, &email).await {
Ok(c) => { Ok(c) => {
state.clients.insert(email.clone(), c.clone()); state.clients.insert(email.clone(), c.clone());
Ok(c) Ok(c)
@@ -56,6 +76,88 @@ impl Actor for MatrixManagerActor {
log::warn!("Failed to send client information: {e}") log::warn!("Failed to send client information: {e}")
} }
} }
MatrixManagerMsg::DisconnectClient(email) => {
if let Some(c) = state.clients.remove(&email) {
// Stop sync thread (if running)
if let Some(id) = state.running_sync_threads.remove(&email) {
state
.broadcast_sender
.send(BroadcastMessage::StopSyncThread(id))
.ok();
}
// Disconnect client
if let Err(e) = c.disconnect().await {
log::error!("Failed to disconnect client: {e}");
}
if let Err(e) = state
.broadcast_sender
.send(BroadcastMessage::UserDisconnectedFromMatrix(email))
{
log::warn!(
"Failed to notify that user has been disconnected from Matrix! {e}"
);
}
}
}
MatrixManagerMsg::StartSyncThread(email) => {
// Do nothing if task is already running
if state.running_sync_threads.contains_key(&email) {
log::debug!("Not starting sync thread for {email:?} as it is already running");
return Ok(());
}
let Some(client) = state.clients.get(&email) else {
log::warn!(
"Cannot start sync thread for {email:?} because client is not initialized!"
);
return Ok(());
};
if !client.is_client_connected() {
log::warn!(
"Cannot start sync thread for {email:?} because Matrix account is not set!"
);
return Ok(());
}
// Start thread
log::debug!("Starting sync thread for {email:?}");
let thread_id =
match start_sync_thread(client.clone(), state.broadcast_sender.clone(), myself)
.await
{
Ok(thread_id) => thread_id,
Err(e) => {
log::error!("Failed to start sync thread! {e}");
return Ok(());
}
};
state.running_sync_threads.insert(email, thread_id);
}
MatrixManagerMsg::StopSyncThread(email) => {
if let Some(thread_id) = state.running_sync_threads.get(&email)
&& let Err(e) = state
.broadcast_sender
.send(BroadcastMessage::StopSyncThread(thread_id.clone()))
{
log::error!("Failed to request sync thread stop: {e}");
}
}
MatrixManagerMsg::SyncThreadGetStatus(email, reply) => {
let started = state.running_sync_threads.contains_key(&email);
if let Err(e) = reply.send(started) {
log::error!("Failed to send sync thread status! {e}");
}
}
MatrixManagerMsg::SyncThreadTerminated(email, task_id) => {
if state.running_sync_threads.get(&email) == Some(&task_id) {
log::info!(
"Sync thread {task_id:?} has been terminated, removing it from the list..."
);
state.running_sync_threads.remove(&email);
}
}
} }
Ok(()) Ok(())
} }

View File

@@ -1,2 +1,3 @@
pub mod matrix_client; pub mod matrix_client;
pub mod matrix_manager; pub mod matrix_manager;
pub mod sync_thread;

View File

@@ -0,0 +1,155 @@
//! # Matrix sync thread
//!
//! This file contains the logic performed by the threads that synchronize with Matrix account.
use crate::broadcast_messages::{BroadcastMessage, BroadcastSender, BxRoomEvent};
use crate::matrix_connection::matrix_client::MatrixClient;
use crate::matrix_connection::matrix_manager::MatrixManagerMsg;
use futures_util::StreamExt;
use matrix_sdk::Room;
use matrix_sdk::ruma::events::reaction::OriginalSyncReactionEvent;
use matrix_sdk::ruma::events::room::message::OriginalSyncRoomMessageEvent;
use matrix_sdk::ruma::events::room::redaction::OriginalSyncRoomRedactionEvent;
use ractor::ActorRef;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MatrixSyncTaskID(uuid::Uuid);
/// Start synchronization thread for a given user
pub async fn start_sync_thread(
client: MatrixClient,
tx: BroadcastSender,
manager: ActorRef<MatrixManagerMsg>,
) -> anyhow::Result<MatrixSyncTaskID> {
// Perform initial synchronization here, so in case of error the sync task does not get registered
log::info!("Perform initial synchronization...");
if let Err(e) = client.perform_initial_sync().await {
log::error!("Failed to perform initial Matrix synchronization! {e:?}");
return Err(e);
}
let task_id = MatrixSyncTaskID(uuid::Uuid::new_v4());
let task_id_clone = task_id.clone();
tokio::task::spawn(async move {
sync_thread_task(task_id_clone, client, tx, manager).await;
});
Ok(task_id)
}
/// Sync thread function for a single function
async fn sync_thread_task(
id: MatrixSyncTaskID,
client: MatrixClient,
tx: BroadcastSender,
manager: ActorRef<MatrixManagerMsg>,
) {
let mut rx = tx.subscribe();
log::info!("Sync thread {id:?} started for user {:?}", client.email);
let mut sync_stream = client.sync_stream().await;
let mut handlers = vec![];
let tx_msg_handle = tx.clone();
let user_msg_handle = client.email.clone();
handlers.push(client.add_event_handler(
async move |event: OriginalSyncRoomMessageEvent, room: Room| {
if let Err(e) = tx_msg_handle.send(BroadcastMessage::RoomMessageEvent(BxRoomEvent {
user: user_msg_handle.clone(),
data: Box::new(event),
room,
})) {
log::warn!("Failed to forward room message event! {e}");
}
},
));
let tx_reac_handle = tx.clone();
let user_reac_handle = client.email.clone();
handlers.push(client.add_event_handler(
async move |event: OriginalSyncReactionEvent, room: Room| {
if let Err(e) = tx_reac_handle.send(BroadcastMessage::ReactionEvent(BxRoomEvent {
user: user_reac_handle.clone(),
data: Box::new(event),
room,
})) {
log::warn!("Failed to forward reaction event! {e}");
}
},
));
let tx_redac_handle = tx.clone();
let user_redac_handle = client.email.clone();
handlers.push(client.add_event_handler(
async move |event: OriginalSyncRoomRedactionEvent, room: Room| {
if let Err(e) =
tx_redac_handle.send(BroadcastMessage::RoomRedactionEvent(BxRoomEvent {
user: user_redac_handle.clone(),
data: Box::new(event),
room,
}))
{
log::warn!("Failed to forward reaction event! {e}");
}
},
));
loop {
tokio::select! {
// Message from tokio broadcast
msg = rx.recv() => {
match msg {
Ok(BroadcastMessage::StopSyncThread(task_id)) if task_id == id => {
log::info!("A request was received to stop sync task! {id:?} for user {:?}", client.email);
break;
}
Err(e) => {
log::error!("Failed to receive a message from broadcast! {e}");
break;
}
Ok(_) => {}
}
}
res = sync_stream.next() => {
let Some(res)= res else {
log::error!("No more Matrix event to process, stopping now...");
break;
};
// Forward message
match res {
Ok(res) => {
if let Err(e)= tx.send(BroadcastMessage::MatrixSyncResponse {
user: client.email.clone(),
sync: res
}) {
log::warn!("Failed to forward room event! {e}");
}
}
Err(e) => {
log::error!("Sync error for user {:?}! {e}", client.email);
}
}
}
}
}
for h in handlers {
client.remove_event_handler(h);
}
// Notify manager about termination, so this thread can be removed from the list
log::info!("Sync thread {id:?} terminated!");
if let Err(e) = ractor::cast!(
manager,
MatrixManagerMsg::SyncThreadTerminated(client.email.clone(), id.clone())
) {
log::error!("Failed to notify Matrix manager about thread termination! {e}");
}
if let Err(e) = tx.send(BroadcastMessage::SyncThreadStopped(id)) {
log::warn!("Failed to notify that synchronization thread has been interrupted! {e}")
}
}

View File

@@ -1,5 +1,11 @@
use crate::app_config::AppConfig; use crate::app_config::AppConfig;
use crate::broadcast_messages::{BroadcastMessage, BroadcastSender};
use crate::constants;
use crate::controllers::server_controller::ServerConstraints;
use crate::matrix_connection::matrix_client::EncryptionRecoveryState;
use crate::utils::rand_utils::rand_string;
use crate::utils::time_utils::time_secs; use crate::utils::time_utils::time_secs;
use anyhow::Context;
use jwt_simple::reexports::serde_json; use jwt_simple::reexports::serde_json;
use std::cmp::min; use std::cmp::min;
use std::str::FromStr; use std::str::FromStr;
@@ -13,6 +19,8 @@ enum MatrixGWUserError {
DecodeUserMetadata(serde_json::Error), DecodeUserMetadata(serde_json::Error),
#[error("Failed to save user metadata: {0}")] #[error("Failed to save user metadata: {0}")]
SaveUserMetadata(std::io::Error), SaveUserMetadata(std::io::Error),
#[error("Failed to create API token directory: {0}")]
CreateApiTokensDirectory(std::io::Error),
#[error("Failed to delete API token: {0}")] #[error("Failed to delete API token: {0}")]
DeleteToken(std::io::Error), DeleteToken(std::io::Error),
#[error("Failed to load API token: {0}")] #[error("Failed to load API token: {0}")]
@@ -100,17 +108,63 @@ impl User {
} }
} }
/// Single API client information /// Base API token information
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] #[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
pub struct APIToken { pub struct BaseAPIToken {
/// Token unique ID /// Token name
pub id: APITokenID, pub name: String,
/// Client description
pub description: String,
/// Restricted API network for token /// Restricted API network for token
pub network: Option<ipnet::IpNet>, pub networks: Option<Vec<ipnet::IpNet>>,
/// Token max inactivity
pub max_inactivity: u32,
/// Token expiration
pub expiration: Option<u64>,
/// Read only access
pub read_only: bool,
}
impl BaseAPIToken {
/// Check API token information validity
pub fn check(&self) -> Option<&'static str> {
let constraints = ServerConstraints::default();
if !lazy_regex::regex!("^[a-zA-Z0-9 :-]+$").is_match(&self.name) {
return Some("Token name contains invalid characters!");
}
if !constraints.token_name.check_str(&self.name) {
return Some("Invalid token name length!");
}
if !constraints
.token_max_inactivity
.check_u32(self.max_inactivity)
{
return Some("Invalid token max inactivity!");
}
if let Some(expiration) = self.expiration
&& expiration <= time_secs()
{
return Some("Given expiration time is in the past!");
}
None
}
}
/// Single API token information
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
pub struct APIToken {
#[serde(flatten)]
pub base: BaseAPIToken,
/// Token unique ID
pub id: APITokenID,
/// Client secret /// Client secret
pub secret: String, pub secret: String,
@@ -120,15 +174,58 @@ pub struct APIToken {
/// Client last usage time /// Client last usage time
pub last_used: u64, pub last_used: u64,
/// Read only access
pub read_only: bool,
/// Token max inactivity
pub max_inactivity: u64,
} }
impl APIToken { impl APIToken {
/// Get the list of tokens of a user
pub async fn list_user(email: &UserEmail) -> anyhow::Result<Vec<Self>> {
let tokens_dir = AppConfig::get().user_api_token_directory(email);
if !tokens_dir.exists() {
return Ok(vec![]);
}
let mut list = vec![];
for u in std::fs::read_dir(&tokens_dir)? {
let entry = u?;
list.push(
Self::load(
email,
&APITokenID::from_str(
entry
.file_name()
.to_str()
.context("Cannot decode API Token ID as string!")?,
)?,
)
.await?,
);
}
Ok(list)
}
/// Create a new token
pub async fn create(email: &UserEmail, base: BaseAPIToken) -> anyhow::Result<Self> {
let tokens_dir = AppConfig::get().user_api_token_directory(email);
if !tokens_dir.exists() {
std::fs::create_dir_all(tokens_dir)
.map_err(MatrixGWUserError::CreateApiTokensDirectory)?;
}
let token = APIToken {
base,
id: Default::default(),
secret: rand_string(constants::TOKENS_LEN),
created: time_secs(),
last_used: time_secs(),
};
token.write(email).await?;
Ok(token)
}
/// Get a token information /// Get a token information
pub async fn load(email: &UserEmail, id: &APITokenID) -> anyhow::Result<Self> { pub async fn load(email: &UserEmail, id: &APITokenID) -> anyhow::Result<Self> {
let token_file = AppConfig::get().user_api_token_metadata_file(email, id); let token_file = AppConfig::get().user_api_token_metadata_file(email, id);
@@ -150,20 +247,33 @@ impl APIToken {
} }
/// Delete this token /// Delete this token
pub async fn delete(self, email: &UserEmail) -> anyhow::Result<()> { pub async fn delete(self, email: &UserEmail, tx: &BroadcastSender) -> anyhow::Result<()> {
let token_file = AppConfig::get().user_api_token_metadata_file(email, &self.id); let token_file = AppConfig::get().user_api_token_metadata_file(email, &self.id);
std::fs::remove_file(&token_file).map_err(MatrixGWUserError::DeleteToken)?; std::fs::remove_file(&token_file).map_err(MatrixGWUserError::DeleteToken)?;
if let Err(e) = tx.send(BroadcastMessage::APITokenDeleted(self)) {
log::error!("Failed to notify API token deletion! {e}");
}
Ok(()) Ok(())
} }
pub fn shall_update_time_used(&self) -> bool { pub fn shall_update_time_used(&self) -> bool {
let refresh_interval = min(600, self.max_inactivity / 10); let refresh_interval = min(600, self.base.max_inactivity / 10);
(self.last_used) < time_secs() - refresh_interval (self.last_used) < time_secs() - refresh_interval as u64
} }
pub fn is_expired(&self) -> bool { pub fn is_expired(&self) -> bool {
(self.last_used + self.max_inactivity) < time_secs() // Check for hard coded expiration
if let Some(exp_time) = self.base.expiration
&& exp_time < time_secs()
{
return true;
}
// Control max token inactivity
(self.last_used + self.base.max_inactivity as u64) < time_secs()
} }
} }
@@ -171,14 +281,8 @@ impl APIToken {
pub struct ExtendedUserInfo { pub struct ExtendedUserInfo {
#[serde(flatten)] #[serde(flatten)]
pub user: User, pub user: User,
pub matrix_account_connected: bool,
pub matrix_user_id: Option<String>, pub matrix_user_id: Option<String>,
} pub matrix_device_id: Option<String>,
pub matrix_recovery_state: EncryptionRecoveryState,
impl ExtendedUserInfo {
pub async fn from_user(user: User) -> anyhow::Result<Self> {
Ok(Self {
user,
matrix_user_id: None, // TODO
})
}
} }

View File

@@ -1,6 +1,11 @@
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256, Sha512};
/// Compute SHA256sum of a given string /// Compute SHA256sum of a given string
pub fn sha256str(input: &str) -> String { pub fn sha256str(input: &str) -> String {
hex::encode(Sha256::digest(input.as_bytes())) hex::encode(Sha256::digest(input.as_bytes()))
} }
/// Compute SHA256sum of a given byte array
pub fn sha512(input: &[u8]) -> String {
hex::encode(Sha512::digest(input))
}

View File

@@ -1,73 +1,2 @@
# React + TypeScript + Vite # MatrixGW frontend
Built using React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@@ -15,8 +15,15 @@
"@mdi/react": "^1.6.1", "@mdi/react": "^1.6.1",
"@mui/icons-material": "^7.3.5", "@mui/icons-material": "^7.3.5",
"@mui/material": "^7.3.5", "@mui/material": "^7.3.5",
"@mui/x-data-grid": "^8.18.0",
"@mui/x-date-pickers": "^8.17.0",
"date-and-time": "^4.1.0",
"dayjs": "^1.11.19",
"is-cidr": "^6.0.1",
"qrcode.react": "^4.2.0",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-json-view-lite": "^2.5.0",
"react-router": "^7.9.5" "react-router": "^7.9.5"
}, },
"devDependencies": { "devDependencies": {
@@ -1039,6 +1046,198 @@
} }
} }
}, },
"node_modules/@mui/x-data-grid": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-8.18.0.tgz",
"integrity": "sha512-g8y5EI3TNqrimHpH/Hv6u6i04cbvsqh39Tg4bZEhGq+SDxWp42iABlUvB7p+gtXfyd+IbmpfzUQ1hOCsHlTMZw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/utils": "^7.3.5",
"@mui/x-internals": "8.18.0",
"@mui/x-virtualizer": "0.2.8",
"clsx": "^2.1.1",
"prop-types": "^15.8.1",
"use-sync-external-store": "^1.6.0"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@emotion/react": "^11.9.0",
"@emotion/styled": "^11.8.1",
"@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0",
"@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/react": {
"optional": true
},
"@emotion/styled": {
"optional": true
}
}
},
"node_modules/@mui/x-data-grid/node_modules/@mui/x-internals": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.18.0.tgz",
"integrity": "sha512-iM2SJALLo4kNqxTel8lfjIymYV9MgTa6021/rAlfdh/vwPMglaKyXQHrxkkWs2Eu/JFKkCKr5Fd34Gsdp63wIg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/utils": "^7.3.5",
"reselect": "^5.1.1",
"use-sync-external-store": "^1.6.0"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@mui/x-date-pickers": {
"version": "8.17.0",
"resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-8.17.0.tgz",
"integrity": "sha512-mrrkTJ1+r6MsPnKH/N5lCNJHkP0dZc2Fvd8fp5tyxa0jRyzwbxJKsadXooccoJWp65Z2vUjUuctXYUmubYP/Sg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/utils": "^7.3.3",
"@mui/x-internals": "8.17.0",
"@types/react-transition-group": "^4.4.12",
"clsx": "^2.1.1",
"prop-types": "^15.8.1",
"react-transition-group": "^4.4.5"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@emotion/react": "^11.9.0",
"@emotion/styled": "^11.8.1",
"@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0",
"@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0",
"date-fns": "^2.25.0 || ^3.2.0 || ^4.0.0",
"date-fns-jalali": "^2.13.0-0 || ^3.2.0-0 || ^4.0.0-0",
"dayjs": "^1.10.7",
"luxon": "^3.0.2",
"moment": "^2.29.4",
"moment-hijri": "^2.1.2 || ^3.0.0",
"moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/react": {
"optional": true
},
"@emotion/styled": {
"optional": true
},
"date-fns": {
"optional": true
},
"date-fns-jalali": {
"optional": true
},
"dayjs": {
"optional": true
},
"luxon": {
"optional": true
},
"moment": {
"optional": true
},
"moment-hijri": {
"optional": true
},
"moment-jalaali": {
"optional": true
}
}
},
"node_modules/@mui/x-internals": {
"version": "8.17.0",
"resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.17.0.tgz",
"integrity": "sha512-KvmR0PPX1j2i44y0DXwzs45jIPMu/YZYXYy7xvzo+ZNdYebbW5LbVeG4zdEUnKHyOG02oHdI7MM9AxcZE16TBw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/utils": "^7.3.3",
"reselect": "^5.1.1",
"use-sync-external-store": "^1.6.0"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@mui/x-virtualizer": {
"version": "0.2.8",
"resolved": "https://registry.npmjs.org/@mui/x-virtualizer/-/x-virtualizer-0.2.8.tgz",
"integrity": "sha512-hCkhTg3BLLbf0SIw9Cx/NHTCUmbna+P5F2V+Bcv/9XiYhfzzmhYnm68+V6vOOhKVbV3j8JKsUEqcTC9K2Jpu8A==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/utils": "^7.3.5",
"@mui/x-internals": "8.18.0"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@mui/x-virtualizer/node_modules/@mui/x-internals": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.18.0.tgz",
"integrity": "sha512-iM2SJALLo4kNqxTel8lfjIymYV9MgTa6021/rAlfdh/vwPMglaKyXQHrxkkWs2Eu/JFKkCKr5Fd34Gsdp63wIg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/utils": "^7.3.5",
"reselect": "^5.1.1",
"use-sync-external-store": "^1.6.0"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz",
@@ -1987,6 +2186,18 @@
"url": "https://github.com/chalk/chalk?sponsor=1" "url": "https://github.com/chalk/chalk?sponsor=1"
} }
}, },
"node_modules/cidr-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/cidr-regex/-/cidr-regex-5.0.1.tgz",
"integrity": "sha512-2Apfc6qH9uwF3QHmlYBA8ExB9VHq+1/Doj9sEMY55TVBcpQ3y/+gmMpcNIBBtfb5k54Vphmta+1IxjMqPlWWAA==",
"license": "BSD-2-Clause",
"dependencies": {
"ip-regex": "5.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/clsx": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -2085,6 +2296,21 @@
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/date-and-time": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-and-time/-/date-and-time-4.1.0.tgz",
"integrity": "sha512-tFdrmBPZrR7bun6jqmlEy/dsjV2JLeUdGALfbKdB7mf0ItMNkYYklxjFE0voGg5oapIaE7WctMClkuRzyU9pig==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/dayjs": {
"version": "1.11.19",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
"license": "MIT"
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -2615,12 +2841,36 @@
"node": ">=0.8.19" "node": ">=0.8.19"
} }
}, },
"node_modules/ip-regex": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-5.0.0.tgz",
"integrity": "sha512-fOCG6lhoKKakwv+C6KdsOnGvgXnmgfmp0myi3bcNwj3qfwPAxRKWEuFhvEFF7ceYIz6+1jRZ+yguLFAmUNPEfw==",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-arrayish": { "node_modules/is-arrayish": {
"version": "0.2.1", "version": "0.2.1",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
"integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/is-cidr": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/is-cidr/-/is-cidr-6.0.1.tgz",
"integrity": "sha512-JIJlvXodfsoWFAvvjB7Elqu8qQcys2SZjkIJCLdk4XherUqZ6+zH7WIpXkp4B3ZxMH0Fz7zIsZwyvs6JfM0csw==",
"license": "BSD-2-Clause",
"dependencies": {
"cidr-regex": "5.0.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/is-core-module": { "node_modules/is-core-module": {
"version": "2.16.1", "version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
@@ -3368,6 +3618,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/qrcode.react": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -3416,6 +3675,18 @@
"integrity": "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==", "integrity": "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-json-view-lite": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/react-json-view-lite/-/react-json-view-lite-2.5.0.tgz",
"integrity": "sha512-tk7o7QG9oYyELWHL8xiMQ8x4WzjCzbWNyig3uexmkLb54r8jO0yH3WCWx8UZS0c49eSA4QUmG5caiRJ8fAn58g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0"
}
},
"node_modules/react-refresh": { "node_modules/react-refresh": {
"version": "0.18.0", "version": "0.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
@@ -3464,6 +3735,12 @@
"react-dom": ">=16.6.0" "react-dom": ">=16.6.0"
} }
}, },
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.11", "version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -3858,6 +4135,15 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/vite": { "node_modules/vite": {
"name": "rolldown-vite", "name": "rolldown-vite",
"version": "7.1.14", "version": "7.1.14",

View File

@@ -17,8 +17,15 @@
"@mdi/react": "^1.6.1", "@mdi/react": "^1.6.1",
"@mui/icons-material": "^7.3.5", "@mui/icons-material": "^7.3.5",
"@mui/material": "^7.3.5", "@mui/material": "^7.3.5",
"@mui/x-data-grid": "^8.18.0",
"@mui/x-date-pickers": "^8.17.0",
"date-and-time": "^4.1.0",
"dayjs": "^1.11.19",
"is-cidr": "^6.0.1",
"qrcode.react": "^4.2.0",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-json-view-lite": "^2.5.0",
"react-router": "^7.9.5" "react-router": "^7.9.5"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -7,11 +7,14 @@ import {
} from "react-router"; } from "react-router";
import { AuthApi } from "./api/AuthApi"; import { AuthApi } from "./api/AuthApi";
import { ServerApi } from "./api/ServerApi"; import { ServerApi } from "./api/ServerApi";
import { APITokensRoute } from "./routes/APITokensRoute";
import { LoginRoute } from "./routes/auth/LoginRoute"; import { LoginRoute } from "./routes/auth/LoginRoute";
import { OIDCCbRoute } from "./routes/auth/OIDCCbRoute"; import { OIDCCbRoute } from "./routes/auth/OIDCCbRoute";
import { HomeRoute } from "./routes/HomeRoute"; import { HomeRoute } from "./routes/HomeRoute";
import { MatrixAuthCallback } from "./routes/MatrixAuthCallback";
import { MatrixLinkRoute } from "./routes/MatrixLinkRoute"; import { MatrixLinkRoute } from "./routes/MatrixLinkRoute";
import { NotFoundRoute } from "./routes/NotFoundRoute"; import { NotFoundRoute } from "./routes/NotFoundRoute";
import { WSDebugRoute } from "./routes/WSDebugRoute";
import { BaseLoginPage } from "./widgets/auth/BaseLoginPage"; import { BaseLoginPage } from "./widgets/auth/BaseLoginPage";
import BaseAuthenticatedPage from "./widgets/dashboard/BaseAuthenticatedPage"; import BaseAuthenticatedPage from "./widgets/dashboard/BaseAuthenticatedPage";
@@ -39,6 +42,9 @@ export function App(): React.ReactElement {
<Route path="*" element={<BaseAuthenticatedPage />}> <Route path="*" element={<BaseAuthenticatedPage />}>
<Route path="" element={<HomeRoute />} /> <Route path="" element={<HomeRoute />} />
<Route path="matrix_link" element={<MatrixLinkRoute />} /> <Route path="matrix_link" element={<MatrixLinkRoute />} />
<Route path="matrix_auth_cb" element={<MatrixAuthCallback />} />
<Route path="tokens" element={<APITokensRoute />} />
<Route path="wsdebug" element={<WSDebugRoute />} />
<Route path="*" element={<NotFoundRoute />} /> <Route path="*" element={<NotFoundRoute />} />
</Route> </Route>
) : ( ) : (

View File

@@ -6,7 +6,10 @@ export interface UserInfo {
time_update: number; time_update: number;
name: string; name: string;
email: string; email: string;
matrix_account_connected: boolean;
matrix_user_id?: string; matrix_user_id?: string;
matrix_device_id?: string;
matrix_recovery_state?: "Enabled" | "Disabled" | "Unknown" | "Incomplete";
} }
const TokenStateKey = "auth-state"; const TokenStateKey = "auth-state";

View File

@@ -12,4 +12,36 @@ export class MatrixLinkApi {
}) })
).data; ).data;
} }
/**
* Finish Matrix Account login
*/
static async FinishAuth(code: string, state: string): Promise<void> {
await APIClient.exec({
uri: "/matrix_link/finish_auth",
method: "POST",
jsonData: { code, state },
});
}
/**
* Disconnect from Matrix Account
*/
static async Disconnect(): Promise<void> {
await APIClient.exec({
uri: "/matrix_link/logout",
method: "POST",
});
}
/**
* Set a new user recovery key
*/
static async SetRecoveryKey(key: string): Promise<void> {
await APIClient.exec({
uri: "/matrix_link/set_recovery_key",
method: "POST",
jsonData: { key },
});
}
} }

View File

@@ -0,0 +1,34 @@
import { APIClient } from "./ApiClient";
export class MatrixSyncApi {
/**
* Start sync thread
*/
static async Start(): Promise<void> {
await APIClient.exec({
method: "POST",
uri: "/matrix_sync/start",
});
}
/**
* Stop sync thread
*/
static async Stop(): Promise<void> {
await APIClient.exec({
method: "POST",
uri: "/matrix_sync/stop",
});
}
/**
* Get sync thread status
*/
static async Status(): Promise<boolean> {
const res = await APIClient.exec({
method: "GET",
uri: "/matrix_sync/status",
});
return res.data.started;
}
}

View File

@@ -0,0 +1,58 @@
import { APIClient } from "./ApiClient";
export interface BaseToken {
name: string;
networks?: string[];
max_inactivity: number;
expiration?: number;
read_only: boolean;
}
export interface Token extends BaseToken {
id: number;
created: number;
last_used: number;
}
export interface TokenWithSecret extends Token {
secret: string;
}
export class TokensApi {
/**
* Get the list of tokens of the current user
*/
static async GetList(): Promise<Token[]> {
return (
await APIClient.exec({
uri: "/tokens",
method: "GET",
})
).data;
}
/**
* Create a new token
*/
static async Create(t: BaseToken): Promise<TokenWithSecret> {
return (
await APIClient.exec({
uri: "/token",
method: "POST",
jsonData: t,
})
).data;
}
/**
* Delete a token
*/
static async Delete(t: Token): Promise<void> {
return (
await APIClient.exec({
uri: `/token/${t.id}`,
method: "DELETE",
})
).data;
}
}

View File

@@ -0,0 +1,15 @@
import { APIClient } from "./ApiClient";
export type WsMessage = {
type: string;
[k: string]: any;
};
export class WsApi {
/**
* Get WebSocket URL
*/
static get WsURL(): string {
return APIClient.backendURL() + "/ws";
}
}

View File

@@ -0,0 +1,70 @@
import { APIClient } from "../ApiClient";
import type { Room } from "./MatrixApiRoom";
export interface MatrixRoomMessage {
type: "m.room.message";
content: {
body: string;
msgtype: "m.text" | "m.image" | string;
"m.relates_to"?: {
event_id: string;
rel_type: "m.replace" | string;
};
file?: {
url: string;
};
};
}
export interface MatrixReaction {
type: "m.reaction";
content: {
"m.relates_to": {
event_id: string;
key: string;
};
};
}
export interface MatrixRoomRedaction {
type: "m.room.redaction";
redacts: string;
}
export type MatrixEventData =
| MatrixRoomMessage
| MatrixReaction
| MatrixRoomRedaction
| { type: "other" };
export interface MatrixEvent {
id: string;
time: number;
sender: string;
data: MatrixEventData;
}
export interface MatrixEventsList {
start: string;
end?: string;
events: MatrixEvent[];
}
export class MatrixApiEvent {
/**
* Get Matrix room events
*/
static async GetRoomEvents(
room: Room,
from?: string
): Promise<MatrixEventsList> {
return (
await APIClient.exec({
method: "GET",
uri:
`/matrix/room/${encodeURIComponent(room.id)}/events` +
(from ? `?from=${from}` : ""),
})
).data;
}
}

View File

@@ -0,0 +1,12 @@
import { APIClient } from "../ApiClient";
export class MatrixApiMedia {
/**
* Get media URL
*/
static MediaURL(url: string, thumbnail: boolean): string {
return `${APIClient.ActualBackendURL()}/matrix/media/${encodeURIComponent(
url
)}?thumbnail=${thumbnail}`;
}
}

View File

@@ -0,0 +1,26 @@
import { APIClient } from "../ApiClient";
export interface UserProfile {
user_id: string;
display_name?: string;
avatar?: string;
}
export type UsersMap = Map<string, UserProfile>;
export class MatrixApiProfile {
/**
* Get multiple profiles information
*/
static async GetMultiple(ids: string[]): Promise<UsersMap> {
const list: UserProfile[] = (
await APIClient.exec({
method: "POST",
uri: "/matrix/profile/get_multiple",
jsonData: ids,
})
).data;
return new Map(list.map((e) => [e.user_id, e]));
}
}

View File

@@ -0,0 +1,55 @@
import { APIClient } from "../ApiClient";
import type { UserInfo } from "../AuthApi";
import type { MatrixEvent } from "./MatrixApiEvent";
import type { UsersMap } from "./MatrixApiProfile";
export interface Room {
id: string;
name?: string;
members: string[];
avatar?: string;
is_space?: boolean;
parents: string[];
number_unread_messages: number;
latest_event?: MatrixEvent;
}
/**
* Find main member of room
*/
export function mainRoomMember(user: UserInfo, r: Room): string | undefined {
if (r.members.length <= 1) return r.members[0];
if (r.members.length < 2)
return r.members[0] == user.matrix_user_id ? r.members[1] : r.members[0];
return undefined;
}
/**
* Find room name
*/
export function roomName(user: UserInfo, r: Room, users: UsersMap): string {
if (r.name) return r.name;
const name = r.members
.filter((m) => m !== user.matrix_user_id)
.map((m) => users.get(m)?.display_name ?? m)
.join(",");
return name === "" ? "Empty room" : name;
}
export class MatrixApiRoom {
/**
* Get the list of joined rooms
*/
static async ListJoined(): Promise<Room[]> {
return (
await APIClient.exec({
method: "GET",
uri: "/matrix/room/joined",
})
).data;
}
}

View File

@@ -0,0 +1,159 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
} from "@mui/material";
import React from "react";
import { ServerApi } from "../api/ServerApi";
import {
TokensApi,
type BaseToken,
type TokenWithSecret,
} from "../api/TokensApi";
import { useAlert } from "../hooks/contexts_provider/AlertDialogProvider";
import { useLoadingMessage } from "../hooks/contexts_provider/LoadingMessageProvider";
import { time } from "../utils/DateUtils";
import {
checkConstraint,
checkNumberConstraint,
isIPNetworkValid,
} from "../utils/FormUtils";
import { CheckboxInput } from "../widgets/forms/CheckboxInput";
import { DateInput } from "../widgets/forms/DateInput";
import { NetworksInput } from "../widgets/forms/NetworksInput";
import { TextInput } from "../widgets/forms/TextInput";
const SECS_IN_DAY = 3600 * 24;
export function CreateTokenDialog(p: {
open: boolean;
onClose: () => void;
onCreated: (t: TokenWithSecret) => void;
}): React.ReactElement {
const alert = useAlert();
const loadingMessage = useLoadingMessage();
const [newTokenUndef, setNewToken] = React.useState<BaseToken | undefined>();
const newToken: BaseToken = newTokenUndef ?? {
name: "",
max_inactivity: 3600 * 24 * 90,
read_only: false,
};
const valid =
checkConstraint(ServerApi.Config.constraints.token_name, newToken.name) ===
undefined &&
checkNumberConstraint(
ServerApi.Config.constraints.token_max_inactivity,
newToken.max_inactivity
) === undefined &&
(newToken.networks === undefined ||
newToken.networks.every((n) => isIPNetworkValid(n)));
const handleSubmit = async () => {
try {
loadingMessage.show("Creating access token...");
const token = await TokensApi.Create(newToken);
p.onCreated(token);
// Clear form
setNewToken(undefined);
} catch (e) {
console.error(`Failed to create token! ${e}`);
alert(`Failed to create API token! ${e}`);
} finally {
loadingMessage.hide();
}
};
return (
<Dialog open={p.open} onClose={p.onClose}>
<DialogTitle>Create new API token</DialogTitle>
<DialogContent>
<TextInput
editable
required
label="Token name"
value={newToken.name}
onValueChange={(v) => {
setNewToken({
...newToken,
name: v ?? "",
});
}}
size={ServerApi.Config.constraints.token_name}
/>
<NetworksInput
editable
label="Allowed networks (CIDR notation)"
value={newToken.networks}
onChange={(v) => {
setNewToken({
...newToken,
networks: v,
});
}}
/>
<TextInput
editable
required
label="Max inactivity period (days)"
type="number"
value={(newToken.max_inactivity / SECS_IN_DAY).toString()}
onValueChange={(i) => {
setNewToken({
...newToken,
max_inactivity: Number(i) * SECS_IN_DAY,
});
}}
size={{
min:
ServerApi.Config.constraints.token_max_inactivity.min /
SECS_IN_DAY,
max:
ServerApi.Config.constraints.token_max_inactivity.max /
SECS_IN_DAY,
}}
/>
<DateInput
editable
label="Expiration date (optional)"
value={newToken.expiration}
onChange={(i) => {
setNewToken((t) => {
return {
...(t ?? newToken),
expiration: i ?? undefined,
};
});
}}
disablePast
checkValue={(s) => s > time()}
/>
<CheckboxInput
editable
label="Read only"
checked={newToken.read_only}
onValueChange={(v) => {
setNewToken({
...newToken,
read_only: v,
});
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={p.onClose}>Cancel</Button>
<Button onClick={handleSubmit} disabled={!valid} autoFocus>
Create token
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,73 @@
import {
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
TextField,
DialogActions,
Button,
} from "@mui/material";
import { MatrixLinkApi } from "../api/MatrixLinkApi";
import { useAlert } from "../hooks/contexts_provider/AlertDialogProvider";
import { useLoadingMessage } from "../hooks/contexts_provider/LoadingMessageProvider";
import { useSnackbar } from "../hooks/contexts_provider/SnackbarProvider";
import React from "react";
import { useUserInfo } from "../widgets/dashboard/BaseAuthenticatedPage";
export function SetRecoveryKeyDialog(p: {
open: boolean;
onClose: () => void;
}): React.ReactElement {
const alert = useAlert();
const snackbar = useSnackbar();
const loadingMessage = useLoadingMessage();
const user = useUserInfo();
const [newKey, setNewKey] = React.useState("");
const handleSubmitKey = async () => {
try {
loadingMessage.show("Updating recovery key...");
await MatrixLinkApi.SetRecoveryKey(newKey);
setNewKey("");
p.onClose();
snackbar("Recovery key successfully updated!");
user.reloadUserInfo();
} catch (e) {
console.error(`Failed to set new recovery key! ${e}`);
alert(`Failed to set new recovery key! ${e}`);
} finally {
loadingMessage.hide();
}
};
return (
<Dialog open={p.open} onClose={p.onClose}>
<DialogTitle>Set new recovery key</DialogTitle>
<DialogContent>
<DialogContentText>
Enter below you recovery key to verify this session and gain access to
old messages.
</DialogContentText>
<TextField
label="Recovery key"
type="text"
variant="standard"
autoComplete="off"
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
fullWidth
/>
</DialogContent>
<DialogActions>
<Button onClick={p.onClose}>Cancel</Button>
<Button onClick={handleSubmitKey} disabled={newKey === ""} autoFocus>
Submit
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -17,18 +17,17 @@ const LoadingMessageContextK =
export function LoadingMessageProvider( export function LoadingMessageProvider(
p: PropsWithChildren p: PropsWithChildren
): React.ReactElement { ): React.ReactElement {
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(0);
const [message, setMessage] = React.useState(""); const [message, setMessage] = React.useState("");
const hook: LoadingMessageContext = { const hook: LoadingMessageContext = {
show(message) { show(message) {
setMessage(message); setMessage(message);
setOpen(true); setOpen((v) => v + 1);
}, },
hide() { hide() {
setMessage(""); setOpen((v) => v - 1);
setOpen(false);
}, },
}; };
@@ -36,7 +35,7 @@ export function LoadingMessageProvider(
<> <>
<LoadingMessageContextK value={hook}>{p.children}</LoadingMessageContextK> <LoadingMessageContextK value={hook}>{p.children}</LoadingMessageContextK>
<Dialog open={open}> <Dialog open={open > 0}>
<DialogContent> <DialogContent>
<DialogContentText> <DialogContentText>
<div <div

View File

@@ -7,3 +7,12 @@ body,
#root { #root {
height: 100%; height: 100%;
} }
#root {
display: flex;
flex-direction: column;
}
#root > div {
flex: 1;
}

View File

@@ -3,39 +3,43 @@ import "@fontsource/roboto/400.css";
import "@fontsource/roboto/500.css"; import "@fontsource/roboto/500.css";
import "@fontsource/roboto/700.css"; import "@fontsource/roboto/700.css";
import { CssBaseline } from "@mui/material";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import "./index.css"; import { ServerApi } from "./api/ServerApi";
import { App } from "./App"; import { App } from "./App";
import { AlertDialogProvider } from "./hooks/contexts_provider/AlertDialogProvider"; import { AlertDialogProvider } from "./hooks/contexts_provider/AlertDialogProvider";
import { ConfirmDialogProvider } from "./hooks/contexts_provider/ConfirmDialogProvider"; import { ConfirmDialogProvider } from "./hooks/contexts_provider/ConfirmDialogProvider";
import { SnackbarProvider } from "./hooks/contexts_provider/SnackbarProvider";
import { LoadingMessageProvider } from "./hooks/contexts_provider/LoadingMessageProvider"; import { LoadingMessageProvider } from "./hooks/contexts_provider/LoadingMessageProvider";
import { AsyncWidget } from "./widgets/AsyncWidget"; import { SnackbarProvider } from "./hooks/contexts_provider/SnackbarProvider";
import { ServerApi } from "./api/ServerApi"; import "./index.css";
import { AppTheme } from "./theme/AppTheme"; import { AppTheme } from "./theme/AppTheme";
import { CssBaseline } from "@mui/material"; import { AsyncWidget } from "./widgets/AsyncWidget";
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<AppTheme> <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="en">
<CssBaseline enableColorScheme /> <AppTheme>
<AlertDialogProvider> <CssBaseline enableColorScheme />
<ConfirmDialogProvider> <AlertDialogProvider>
<SnackbarProvider> <ConfirmDialogProvider>
<LoadingMessageProvider> <SnackbarProvider>
<AsyncWidget <LoadingMessageProvider>
loadKey={1} <AsyncWidget
load={async () => { loadKey={1}
await ServerApi.LoadConfig(); load={async () => {
}} await ServerApi.LoadConfig();
errMsg="Failed to load static server configuration!" }}
build={() => <App />} errMsg="Failed to load static server configuration!"
/> build={() => <App />}
</LoadingMessageProvider> />
</SnackbarProvider> </LoadingMessageProvider>
</ConfirmDialogProvider> </SnackbarProvider>
</AlertDialogProvider> </ConfirmDialogProvider>
</AppTheme> </AlertDialogProvider>
</AppTheme>
</LocalizationProvider>
</StrictMode> </StrictMode>
); );

View File

@@ -0,0 +1,293 @@
import AddIcon from "@mui/icons-material/Add";
import RefreshIcon from "@mui/icons-material/Refresh";
import { Alert, AlertTitle, IconButton, Tooltip } from "@mui/material";
import type { GridColDef } from "@mui/x-data-grid";
import { DataGrid, GridActionsCellItem } from "@mui/x-data-grid";
import { QRCodeCanvas } from "qrcode.react";
import React from "react";
import { APIClient } from "../api/ApiClient";
import { TokensApi, type Token, type TokenWithSecret } from "../api/TokensApi";
import { CreateTokenDialog } from "../dialogs/CreateTokenDialog";
import { AsyncWidget } from "../widgets/AsyncWidget";
import { CopyTextChip } from "../widgets/CopyTextChip";
import { MatrixGWRouteContainer } from "../widgets/MatrixGWRouteContainer";
import { TimeWidget } from "../widgets/TimeWidget";
import DeleteIcon from "@mui/icons-material/DeleteOutlined";
import { useSnackbar } from "../hooks/contexts_provider/SnackbarProvider";
import { useConfirm } from "../hooks/contexts_provider/ConfirmDialogProvider";
import { useAlert } from "../hooks/contexts_provider/AlertDialogProvider";
import { time } from "../utils/DateUtils";
export function APITokensRoute(): React.ReactElement {
const count = React.useRef(0);
const [openCreateTokenDialog, setOpenCreateTokenDialog] =
React.useState(false);
const [createdToken, setCreatedToken] =
React.useState<TokenWithSecret | null>(null);
const [list, setList] = React.useState<Token[] | undefined>();
const load = async () => {
setList(await TokensApi.GetList());
};
const handleRefreshTokensList = () => {
count.current += 1;
setList(undefined);
};
const handleOpenCreateTokenDialog = () => setOpenCreateTokenDialog(true);
const handleCancelCreateToken = () => setOpenCreateTokenDialog(false);
const handleCreatedToken = (s: TokenWithSecret) => {
setCreatedToken(s);
setOpenCreateTokenDialog(false);
handleRefreshTokensList();
};
return (
<MatrixGWRouteContainer
label={"API tokens"}
actions={
<span>
<Tooltip title="Create new token">
<IconButton onClick={handleOpenCreateTokenDialog}>
<AddIcon />
</IconButton>
</Tooltip>
&nbsp;&nbsp;
<Tooltip title="Refresh tokens list">
<IconButton onClick={handleRefreshTokensList}>
<RefreshIcon />
</IconButton>
</Tooltip>
</span>
}
>
{/* Create token dialog anchor */}
<CreateTokenDialog
open={openCreateTokenDialog}
onCreated={handleCreatedToken}
onClose={handleCancelCreateToken}
/>
{/* Info about created token */}
{createdToken && <CreatedToken token={createdToken!} />}
{/* Tokens list */}
<AsyncWidget
loadKey={count.current}
ready={list !== undefined}
load={load}
errMsg="Failed to load the list of tokens!"
build={() => (
<TokensListGrid list={list!} onReload={handleRefreshTokensList} />
)}
/>
</MatrixGWRouteContainer>
);
}
function CreatedToken(p: { token: TokenWithSecret }): React.ReactElement {
return (
<Alert severity="success" style={{ margin: "10px" }}>
<div
style={{
display: "flex",
flexDirection: "row",
}}
>
<div style={{ textAlign: "center", marginRight: "10px" }}>
<div style={{ padding: "15px", backgroundColor: "white" }}>
<QRCodeCanvas
value={`matrixgw://api=${encodeURIComponent(
APIClient.ActualBackendURL()
)}&id=${p.token.id}&secret=${p.token.secret}`}
/>
</div>
<br />
<em>Mobile App Qr Code</em>
</div>
<div>
<AlertTitle>Token successfully created</AlertTitle>
The API token <i>{p.token.name}</i> was successfully created. Please
note the following information as they won't be available after.
<br />
<br />
API URL: <CopyTextChip text={APIClient.ActualBackendURL()} />
<br />
Token ID: <CopyTextChip text={p.token.id.toString()} />
<br />
Token secret: <CopyTextChip text={p.token.secret} />
</div>
</div>
</Alert>
);
}
function TokensListGrid(p: {
list: Token[];
onReload: () => void;
}): React.ReactElement {
const snackbar = useSnackbar();
const confirm = useConfirm();
const alert = useAlert();
// Delete a token
const handleDeleteClick = (token: Token) => async () => {
try {
if (
!(await confirm(
`Do you really want to delete the token named '${token.name}' ?`
))
)
return;
await TokensApi.Delete(token);
p.onReload();
snackbar("The token was successfully deleted!");
} catch (e) {
console.error(e);
alert(`Failed to delete API token! ${e}`);
}
};
const columns: GridColDef<(typeof p.list)[number]>[] = [
{ field: "id", headerName: "ID", flex: 1 },
{
field: "name",
headerName: "Name",
flex: 3,
},
{
field: "networks",
headerName: "Networks restriction",
flex: 3,
renderCell(params) {
return (
params.row.networks?.join(", ") ?? (
<span style={{ fontStyle: "italic" }}>Unrestricted</span>
)
);
},
},
{
field: "created",
headerName: "Creation",
flex: 3,
renderCell(params) {
return <TimeWidget time={params.row.created} />;
},
},
{
field: "last_used",
headerName: "Last usage",
flex: 3,
renderCell(params) {
return (
<span
style={{
color:
params.row.last_used + params.row.max_inactivity < time()
? "red"
: undefined,
}}
>
<TimeWidget time={params.row.last_used} />
</span>
);
},
},
{
field: "max_inactivity",
headerName: "Max inactivity",
flex: 3,
renderCell(params) {
return (
<span
style={{
color:
params.row.last_used + params.row.max_inactivity < time()
? "red"
: undefined,
}}
>
<TimeWidget time={params.row.max_inactivity} isDuration />
</span>
);
},
},
{
field: "expiration",
headerName: "Expiration",
flex: 3,
renderCell(params) {
return (
<span
style={{
color:
params.row.expiration && params.row.expiration < time()
? "red"
: undefined,
}}
>
<TimeWidget time={params.row.expiration} showDate />
</span>
);
},
},
{
field: "read_only",
headerName: "Read only",
flex: 2,
type: "boolean",
},
{
field: "actions",
type: "actions",
headerName: "Actions",
flex: 2,
cellClassName: "actions",
getActions: ({ row }) => {
return [
<GridActionsCellItem
key={row.id}
icon={<DeleteIcon />}
label="Delete"
onClick={handleDeleteClick(row)}
color="inherit"
/>,
];
},
},
];
if (p.list.length === 0)
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
You do not have created any token yet!
</div>
);
return (
<DataGrid
style={{ flex: "1" }}
rows={p.list}
columns={columns}
autoPageSize
getRowId={(c) => c.id}
isCellEditable={() => false}
isRowSelectable={() => false}
/>
);
}

View File

@@ -1,10 +1,11 @@
import { useUserInfo } from "../widgets/dashboard/BaseAuthenticatedPage"; import { useUserInfo } from "../widgets/dashboard/BaseAuthenticatedPage";
import { MainMessageWidget } from "../widgets/messages/MainMessagesWidget";
import { NotLinkedAccountMessage } from "../widgets/NotLinkedAccountMessage"; import { NotLinkedAccountMessage } from "../widgets/NotLinkedAccountMessage";
export function HomeRoute(): React.ReactElement { export function HomeRoute(): React.ReactElement {
const user = useUserInfo(); const user = useUserInfo();
if (!user.info.matrix_user_id) return <NotLinkedAccountMessage />; if (!user.info.matrix_account_connected) return <NotLinkedAccountMessage />;
return <p>Todo home route</p>; return <MainMessageWidget />;
} }

View File

@@ -0,0 +1,81 @@
import { Alert, Box, Button, CircularProgress } from "@mui/material";
import React from "react";
import { useNavigate, useSearchParams } from "react-router";
import { MatrixLinkApi } from "../api/MatrixLinkApi";
import { useUserInfo } from "../widgets/dashboard/BaseAuthenticatedPage";
import { RouterLink } from "../widgets/RouterLink";
import { useSnackbar } from "../hooks/contexts_provider/SnackbarProvider";
export function MatrixAuthCallback(): React.ReactElement {
const navigate = useNavigate();
const snackbar = useSnackbar();
const info = useUserInfo();
const [error, setError] = React.useState<null | string>(null);
const [searchParams] = useSearchParams();
const code = searchParams.get("code");
const state = searchParams.get("state");
const count = React.useRef("");
React.useEffect(() => {
const load = async () => {
try {
if (count.current === code) {
return;
}
count.current = code!;
await MatrixLinkApi.FinishAuth(code!, state!);
snackbar("Successfully linked to Matrix account!");
navigate("/matrix_link");
} catch (e) {
console.error(e);
setError(String(e));
} finally {
info.reloadUserInfo();
}
};
load();
}, [code, state]);
if (error)
return (
<Box
component="div"
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
flex: "1",
flexDirection: "column",
}}
>
<Alert
variant="outlined"
severity="error"
style={{ margin: "0px 15px 15px 15px" }}
>
Failed to finalize Matrix authentication!
<br />
<br />
{error}
</Alert>
<Button>
<RouterLink to="/matrix_link">Go back</RouterLink>
</Button>
</Box>
);
return (
<div style={{ textAlign: "center" }}>
<CircularProgress />
</div>
);
}

View File

@@ -1,15 +1,28 @@
import CheckIcon from "@mui/icons-material/Check";
import CloseIcon from "@mui/icons-material/Close";
import KeyIcon from "@mui/icons-material/Key";
import LinkIcon from "@mui/icons-material/Link"; import LinkIcon from "@mui/icons-material/Link";
import LinkOffIcon from "@mui/icons-material/LinkOff"; import LinkOffIcon from "@mui/icons-material/LinkOff";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import PowerSettingsNewIcon from "@mui/icons-material/PowerSettingsNew";
import StopIcon from "@mui/icons-material/Stop";
import { import {
Button, Button,
Card, Card,
CardActions, CardActions,
CardContent, CardContent,
CircularProgress,
Grid,
Typography, Typography,
} from "@mui/material"; } from "@mui/material";
import React from "react";
import { MatrixLinkApi } from "../api/MatrixLinkApi"; import { MatrixLinkApi } from "../api/MatrixLinkApi";
import { MatrixSyncApi } from "../api/MatrixSyncApi";
import { SetRecoveryKeyDialog } from "../dialogs/SetRecoveryKeyDialog";
import { useAlert } from "../hooks/contexts_provider/AlertDialogProvider"; import { useAlert } from "../hooks/contexts_provider/AlertDialogProvider";
import { useConfirm } from "../hooks/contexts_provider/ConfirmDialogProvider";
import { useLoadingMessage } from "../hooks/contexts_provider/LoadingMessageProvider"; import { useLoadingMessage } from "../hooks/contexts_provider/LoadingMessageProvider";
import { useSnackbar } from "../hooks/contexts_provider/SnackbarProvider";
import { useUserInfo } from "../widgets/dashboard/BaseAuthenticatedPage"; import { useUserInfo } from "../widgets/dashboard/BaseAuthenticatedPage";
import { MatrixGWRouteContainer } from "../widgets/MatrixGWRouteContainer"; import { MatrixGWRouteContainer } from "../widgets/MatrixGWRouteContainer";
@@ -17,7 +30,21 @@ export function MatrixLinkRoute(): React.ReactElement {
const user = useUserInfo(); const user = useUserInfo();
return ( return (
<MatrixGWRouteContainer label={"Matrix account link"}> <MatrixGWRouteContainer label={"Matrix account link"}>
{user.info.matrix_user_id === null ? <ConnectCard /> : <ConnectedCard />} {user.info.matrix_user_id === null ? (
<ConnectCard />
) : (
<Grid container spacing={2}>
<Grid size={{ sm: 12, md: 6 }}>
<ConnectedCard />
</Grid>
<Grid size={{ sm: 12, md: 6 }}>
<EncryptionKeyStatus />
</Grid>
<Grid size={{ sm: 12, md: 6 }}>
<SyncThreadStatus />
</Grid>
</Grid>
)}
</MatrixGWRouteContainer> </MatrixGWRouteContainer>
); );
} }
@@ -68,10 +95,32 @@ function ConnectCard(): React.ReactElement {
} }
function ConnectedCard(): React.ReactElement { function ConnectedCard(): React.ReactElement {
const snackbar = useSnackbar();
const confirm = useConfirm();
const alert = useAlert();
const loadingMessage = useLoadingMessage();
const user = useUserInfo(); const user = useUserInfo();
const handleDisconnect = async () => {
if (!(await confirm("Do you really want to unlink your Matrix account?")))
return;
try {
loadingMessage.show("Unlinking Matrix account...");
await MatrixLinkApi.Disconnect();
snackbar("Successfully unlinked Matrix account!");
} catch (e) {
console.error(`Failed to unlink user account! ${e}`);
alert(`Failed to unlink your account! ${e}`);
} finally {
user.reloadUserInfo();
loadingMessage.hide();
}
};
return ( return (
<Card> <Card style={{ marginBottom: "10px" }}>
<CardContent> <CardContent>
<Typography variant="h5" component="div" gutterBottom> <Typography variant="h5" component="div" gutterBottom>
<i>Connected to your Matrix account</i> <i>Connected to your Matrix account</i>
@@ -79,9 +128,17 @@ function ConnectedCard(): React.ReactElement {
<Typography variant="body1" gutterBottom> <Typography variant="body1" gutterBottom>
<p> <p>
MatrixGW is currently connected to your account with ID{" "} MatrixGW is currently connected to your account with the following
<i>{user.info.matrix_user_id}</i>. information:
</p> </p>
<ul>
<li>
User id: <i>{user.info.matrix_user_id}</i>
</li>
<li>
Device id: <i>{user.info.matrix_device_id}</i>
</li>
</ul>
<p> <p>
If you encounter issues with your Matrix account you can try to If you encounter issues with your Matrix account you can try to
disconnect and connect back again. disconnect and connect back again.
@@ -89,10 +146,184 @@ function ConnectedCard(): React.ReactElement {
</Typography> </Typography>
</CardContent> </CardContent>
<CardActions> <CardActions>
<Button size="small" variant="outlined" startIcon={<LinkOffIcon />}> <Button
size="small"
variant="outlined"
startIcon={<LinkOffIcon />}
onClick={handleDisconnect}
>
Disconnect Disconnect
</Button> </Button>
</CardActions> </CardActions>
</Card> </Card>
); );
} }
function EncryptionKeyStatus(): React.ReactElement {
const user = useUserInfo();
const [openSetKeyDialog, setOpenSetKeyDialog] = React.useState(false);
const handleSetKey = () => setOpenSetKeyDialog(true);
const handleCloseSetKey = () => setOpenSetKeyDialog(false);
return (
<>
<Card>
<CardContent>
<Typography variant="h5" component="div" gutterBottom>
Recovery keys
</Typography>
<Typography variant="body1" gutterBottom>
<p>
Recovery key is used to verify MatrixGW connection and access
message history in encrypted rooms.
</p>
<p>
Current encryption status:{" "}
{user.info.matrix_recovery_state === "Enabled" ? (
<CheckIcon
style={{ display: "inline", verticalAlign: "middle" }}
/>
) : (
<CloseIcon
style={{ display: "inline", verticalAlign: "middle" }}
/>
)}{" "}
{user.info.matrix_recovery_state}
</p>
</Typography>
</CardContent>
<CardActions>
<Button
size="small"
variant="outlined"
startIcon={<KeyIcon />}
onClick={handleSetKey}
>
Set new recovery key
</Button>
</CardActions>
</Card>
{/* Set new key dialog */}
<SetRecoveryKeyDialog
open={openSetKeyDialog}
onClose={handleCloseSetKey}
/>
</>
);
}
function SyncThreadStatus(): React.ReactElement {
const alert = useAlert();
const snackbar = useSnackbar();
const [started, setStarted] = React.useState<undefined | boolean>();
const loadStatus = async () => {
try {
setStarted(await MatrixSyncApi.Status());
} catch (e) {
console.error(`Failed to refresh sync thread status! ${e}`);
snackbar(`Failed to refresh sync thread status! ${e}`);
}
};
const handleStartThread = async () => {
try {
setStarted(undefined);
await MatrixSyncApi.Start();
snackbar("Sync thread started");
} catch (e) {
console.error(`Failed to start sync thread! ${e}`);
alert(`Failed to start sync thread! ${e}`);
}
};
const handleStopThread = async () => {
try {
setStarted(undefined);
await MatrixSyncApi.Stop();
snackbar("Sync thread stopped");
} catch (e) {
console.error(`Failed to stop sync thread! ${e}`);
alert(`Failed to stop sync thread! ${e}`);
}
};
React.useEffect(() => {
const interval = setInterval(loadStatus, 1000);
return () => clearInterval(interval);
}, []);
return (
<>
<Card>
<CardContent>
<Typography variant="h5" component="div" gutterBottom>
Sync thread status
</Typography>
<Typography variant="body1" gutterBottom>
<p>
A thread is spawned on the server to watch for events on the
Matrix server. You can restart this thread from here in case of
issue.
</p>
<p>
Current thread status:{" "}
{started === undefined ? (
<>
<CircularProgress
size={"1rem"}
style={{ verticalAlign: "middle" }}
/>
</>
) : started === true ? (
<>
<CheckIcon
style={{ display: "inline", verticalAlign: "middle" }}
/>{" "}
Started
</>
) : (
<>
<PowerSettingsNewIcon
style={{ display: "inline", verticalAlign: "middle" }}
/>
Stopped
</>
)}
</p>
</Typography>
</CardContent>
<CardActions>
{started === false && (
<Button
size="small"
variant="outlined"
startIcon={<PlayArrowIcon />}
onClick={handleStartThread}
>
Start thread
</Button>
)}
{started === true && (
<Button
size="small"
variant="outlined"
startIcon={<StopIcon />}
onClick={handleStopThread}
>
Stop thread
</Button>
)}
</CardActions>
</Card>
</>
);
}

View File

@@ -0,0 +1,78 @@
import React from "react";
import { JsonView, darkStyles } from "react-json-view-lite";
import "react-json-view-lite/dist/index.css";
import { WsApi, type WsMessage } from "../api/WsApi";
import { useSnackbar } from "../hooks/contexts_provider/SnackbarProvider";
import { time } from "../utils/DateUtils";
import { useUserInfo } from "../widgets/dashboard/BaseAuthenticatedPage";
import { MatrixGWRouteContainer } from "../widgets/MatrixGWRouteContainer";
import { NotLinkedAccountMessage } from "../widgets/NotLinkedAccountMessage";
const State = {
Closed: "Closed",
Connected: "Connected",
Error: "Error",
} as const;
type TimestampedMessages = WsMessage & { time: number };
export function WSDebugRoute(): React.ReactElement {
const user = useUserInfo();
if (!user.info.matrix_account_connected) return <NotLinkedAccountMessage />;
const snackbar = useSnackbar();
const [state, setState] = React.useState<string>(State.Closed);
const wsRef = React.useRef<WebSocket | undefined>(undefined);
const [messages, setMessages] = React.useState<TimestampedMessages[]>([]);
React.useEffect(() => {
const ws = new WebSocket(WsApi.WsURL);
wsRef.current = ws;
ws.onopen = () => setState(State.Connected);
ws.onerror = (e) => {
console.error(`WS Debug error!`, e);
snackbar(`WebSocket error! ${e}`);
setState(State.Error);
};
ws.onclose = () => {
setState(State.Closed);
wsRef.current = undefined;
};
ws.onmessage = (msg) => {
const dec = JSON.parse(msg.data);
setMessages((l) => {
return [{ time: time(), ...dec }, ...l];
});
};
return () => ws.close();
}, []);
return (
<MatrixGWRouteContainer label={"WebSocket Debug"}>
<div>
State:{" "}
<span style={{ color: state == State.Connected ? "green" : "red" }}>
{state}
</span>
</div>
{messages.map((msg, id) => (
<div style={{ margin: "10px", backgroundColor: "black" }}>
<JsonView
key={id}
data={msg}
shouldExpandNode={(level) => level < 2}
style={{
...darkStyles,
container: "",
}}
/>
</div>
))}
</MatrixGWRouteContainer>
);
}

View File

@@ -0,0 +1,8 @@
/**
* Get UNIX time
*
* @returns Number of seconds since Epoch
*/
export function time(): number {
return Math.floor(new Date().getTime() / 1000);
}

View File

@@ -0,0 +1,52 @@
import isCidr from "is-cidr";
import type { LenConstraint } from "../api/ServerApi";
/**
* Check if a constraint was respected or not
*
* @returns An error message appropriate for the constraint
* violation, if any, or undefined otherwise
*/
export function checkConstraint(
constraint: LenConstraint,
value: string | undefined
): string | undefined {
value = value ?? "";
if (value.length < constraint.min)
return `Please specify at least ${constraint.min} characters!`;
if (value.length > constraint.max)
return `Please specify at least ${constraint.min} characters!`;
return undefined;
}
/**
* Check if a number constraint was respected or not
*
* @returns An error message appropriate for the constraint
* violation, if any, or undefined otherwise
*/
export function checkNumberConstraint(
constraint: LenConstraint,
value: number
): string | undefined {
value = value ?? "";
if (value < constraint.min)
return `Value is below accepted minimum (${constraint.min})!`;
if (value > constraint.max)
return `Value is above accepted maximum (${constraint.min})!`;
return undefined;
}
/**
* Check whether a given IP network address is valid or not
*
* @param ip The IP network to check
* @returns true if the address is valid, false otherwise
*/
export function isIPNetworkValid(ip: string): boolean {
return isCidr(ip) !== 0;
}

View File

@@ -0,0 +1,101 @@
import type {
MatrixEvent,
MatrixEventsList,
} from "../api/matrix/MatrixApiEvent";
import type { Room } from "../api/matrix/MatrixApiRoom";
export interface MessageReaction {
event_id: string;
account: string;
key: string;
}
export interface Message {
event_id: string;
sent: number;
modified: boolean;
reactions: MessageReaction[];
content: string;
image?: string;
}
export class RoomEventsManager {
readonly room: Room;
private events: MatrixEvent[];
messages: Message[];
endToken?: string;
constructor(room: Room, initialMessages: MatrixEventsList) {
this.room = room;
this.events = [];
this.messages = [];
this.processNewEvents(initialMessages);
}
/**
* Process events given by the API
*/
processNewEvents(evts: MatrixEventsList) {
this.endToken = evts.end;
this.events = [...this.events, ...evts.events];
this.rebuildMessagesList();
}
private rebuildMessagesList() {
// Sorts events list to process oldest events first
this.events.sort((a, b) => a.time - b.time);
// First, process redactions to skip redacted events
let redacted = new Set(
this.events
.map((e) =>
e.data.type === "m.room.redaction" ? e.data.redacts : undefined
)
.filter((e) => e !== undefined)
);
for (const evt of this.events) {
if (redacted.has(evt.id)) continue;
const data = evt.data;
// Message
if (data.type === "m.room.message") {
// Check if this message replaces another one
if (data.content["m.relates_to"]) {
const message = this.messages.find(
(m) => m.event_id === data.content["m.relates_to"]?.event_id
);
if (!message) continue;
message.modified = true;
message.content = data.content.body;
continue;
}
this.messages.push({
event_id: evt.id,
modified: false,
reactions: [],
sent: evt.time,
image: data.content.file?.url,
content: data.content.body,
});
}
// Reaction
if (data.type === "m.reaction") {
const message = this.messages.find(
(m) => m.event_id === data.content["m.relates_to"].event_id
);
if (!message) continue;
message.reactions.push({
account: evt.sender,
event_id: evt.id,
key: data.content["m.relates_to"].key,
});
}
}
}
}

View File

@@ -0,0 +1,29 @@
import { Chip, Tooltip } from "@mui/material";
import { useAlert } from "../hooks/contexts_provider/AlertDialogProvider";
import { useSnackbar } from "../hooks/contexts_provider/SnackbarProvider";
export function CopyTextChip(p: { text: string }): React.ReactElement {
const snackbar = useSnackbar();
const alert = useAlert();
const copyTextToClipboard = () => {
try {
navigator.clipboard.writeText(p.text);
snackbar(`'${p.text}' was copied to clipboard.`);
} catch (e) {
console.error(`Failed to copy text to the clipboard! ${e}`);
alert(p.text);
}
};
return (
<Tooltip title="Copy to clipboard">
<Chip
label={p.text}
variant="outlined"
style={{ margin: "5px" }}
onClick={copyTextToClipboard}
/>
</Tooltip>
);
}

View File

@@ -0,0 +1,86 @@
import { Tooltip } from "@mui/material";
import { format } from "date-and-time";
import { time } from "../utils/DateUtils";
export function formatDateTime(time: number): string {
const t = new Date();
t.setTime(1000 * time);
return format(t, "DD/MM/YYYY HH:mm:ss");
}
export function formatDate(time: number): string {
const t = new Date();
t.setTime(1000 * time);
return format(t, "DD/MM/YYYY");
}
export function timeDiff(a: number, b: number): string {
let diff = b - a;
if (diff === 0) return "now";
if (diff === 1) return "1 second";
if (diff < 60) {
return `${diff} seconds`;
}
diff = Math.floor(diff / 60);
if (diff === 1) return "1 minute";
if (diff < 60) {
return `${diff} minutes`;
}
diff = Math.floor(diff / 60);
if (diff === 1) return "1 hour";
if (diff < 24) {
return `${diff} hours`;
}
const diffDays = Math.floor(diff / 24);
if (diffDays === 1) return "1 day";
if (diffDays < 31) {
return `${diffDays} days`;
}
diff = Math.floor(diffDays / 31);
if (diff < 12) {
return `${diff} month`;
}
const diffYears = Math.floor(diffDays / 365);
if (diffYears === 1) return "1 year";
return `${diffYears} years`;
}
export function timeDiffFromNow(t: number): string {
return timeDiff(t, time());
}
export function TimeWidget(p: {
time?: number;
isDuration?: boolean;
showDate?: boolean;
}): React.ReactElement {
if (!p.time) return <></>;
return (
<Tooltip
title={formatDateTime(
p.isDuration ? new Date().getTime() / 1000 - p.time : p.time
)}
arrow
>
<span>
{p.showDate
? formatDate(p.time)
: p.isDuration
? timeDiff(0, p.time)
: timeDiffFromNow(p.time)}
</span>
</Tooltip>
);
}

View File

@@ -1,15 +1,16 @@
import { Button } from "@mui/material";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import Toolbar from "@mui/material/Toolbar";
import useMediaQuery from "@mui/material/useMediaQuery"; import useMediaQuery from "@mui/material/useMediaQuery";
import * as React from "react"; import * as React from "react";
import { Outlet, useNavigate } from "react-router"; import { Outlet, useNavigate } from "react-router";
import { AuthApi, type UserInfo } from "../../api/AuthApi";
import { useAuth } from "../../App";
import { useAlert } from "../../hooks/contexts_provider/AlertDialogProvider";
import { useLoadingMessage } from "../../hooks/contexts_provider/LoadingMessageProvider";
import { AsyncWidget } from "../AsyncWidget";
import DashboardHeader from "./DashboardHeader"; import DashboardHeader from "./DashboardHeader";
import DashboardSidebar from "./DashboardSidebar"; import DashboardSidebar from "./DashboardSidebar";
import { AuthApi, type UserInfo } from "../../api/AuthApi";
import { AsyncWidget } from "../AsyncWidget";
import { Button } from "@mui/material";
import { useAuth } from "../../App";
interface UserInfoContext { interface UserInfoContext {
info: UserInfo; info: UserInfo;
@@ -21,12 +22,26 @@ const UserInfoContextK = React.createContext<UserInfoContext | null>(null);
export default function BaseAuthenticatedPage(): React.ReactElement { export default function BaseAuthenticatedPage(): React.ReactElement {
const theme = useTheme(); const theme = useTheme();
const alert = useAlert();
const loadingMessage = useLoadingMessage();
const [userInfo, setuserInfo] = React.useState<null | UserInfo>(null); const [userInfo, setuserInfo] = React.useState<null | UserInfo>(null);
const loadUserInfo = async () => { const loadUserInfo = async () => {
setuserInfo(await AuthApi.GetUserInfo()); setuserInfo(await AuthApi.GetUserInfo());
}; };
const reloadUserInfo = async () => {
try {
loadingMessage.show("Refreshing user information...");
await loadUserInfo();
} catch (e) {
console.error(`Failed to load user information! ${e}`);
alert(`Failed to load user information! ${e}`);
} finally {
loadingMessage.hide();
}
};
const auth = useAuth(); const auth = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -85,24 +100,22 @@ export default function BaseAuthenticatedPage(): React.ReactElement {
<UserInfoContextK <UserInfoContextK
value={{ value={{
info: userInfo!, info: userInfo!,
reloadUserInfo: loadUserInfo, reloadUserInfo,
signOut, signOut,
}} }}
> >
<DashboardHeader
menuOpen={isNavigationExpanded}
onToggleMenu={handleToggleHeaderMenu}
/>
<Box <Box
ref={layoutRef} ref={layoutRef}
sx={{ sx={{
position: "relative", position: "relative",
display: "flex", display: "flex",
overflow: "hidden", overflow: "hidden",
height: "100%",
width: "100%",
}} }}
> >
<DashboardHeader
menuOpen={isNavigationExpanded}
onToggleMenu={handleToggleHeaderMenu}
/>
<DashboardSidebar <DashboardSidebar
expanded={isNavigationExpanded} expanded={isNavigationExpanded}
setExpanded={setIsNavigationExpanded} setExpanded={setIsNavigationExpanded}
@@ -116,7 +129,6 @@ export default function BaseAuthenticatedPage(): React.ReactElement {
minWidth: 0, minWidth: 0,
}} }}
> >
<Toolbar sx={{ displayPrint: "none" }} />
<Box <Box
component="main" component="main"
sx={{ sx={{

View File

@@ -81,7 +81,11 @@ export default function DashboardHeader({
); );
return ( return (
<AppBar color="inherit" position="absolute" sx={{ displayPrint: "none" }}> <AppBar
color="inherit"
position="static"
sx={{ displayPrint: "none", overflow: "hidden" }}
>
<Toolbar sx={{ backgroundColor: "inherit", mx: { xs: -0.75, sm: -1 } }}> <Toolbar sx={{ backgroundColor: "inherit", mx: { xs: -0.75, sm: -1 } }}>
<Stack <Stack
direction="row" direction="row"

View File

@@ -3,11 +3,11 @@ import Icon from "@mdi/react";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Drawer from "@mui/material/Drawer"; import Drawer from "@mui/material/Drawer";
import List from "@mui/material/List"; import List from "@mui/material/List";
import Toolbar from "@mui/material/Toolbar";
import { useTheme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import type {} from "@mui/material/themeCssVarsAugmentation"; import type {} from "@mui/material/themeCssVarsAugmentation";
import useMediaQuery from "@mui/material/useMediaQuery"; import useMediaQuery from "@mui/material/useMediaQuery";
import * as React from "react"; import * as React from "react";
import { useUserInfo } from "./BaseAuthenticatedPage";
import DashboardSidebarContext from "./DashboardSidebarContext"; import DashboardSidebarContext from "./DashboardSidebarContext";
import DashboardSidebarDividerItem from "./DashboardSidebarDividerItem"; import DashboardSidebarDividerItem from "./DashboardSidebarDividerItem";
import DashboardSidebarPageItem from "./DashboardSidebarPageItem"; import DashboardSidebarPageItem from "./DashboardSidebarPageItem";
@@ -27,10 +27,10 @@ export interface DashboardSidebarProps {
export default function DashboardSidebar({ export default function DashboardSidebar({
expanded = true, expanded = true,
setExpanded, setExpanded,
disableCollapsibleSidebar = false,
container, container,
}: DashboardSidebarProps) { }: DashboardSidebarProps) {
const theme = useTheme(); const theme = useTheme();
const user = useUserInfo();
const isOverSmViewport = useMediaQuery(theme.breakpoints.up("sm")); const isOverSmViewport = useMediaQuery(theme.breakpoints.up("sm"));
const isOverMdViewport = useMediaQuery(theme.breakpoints.up("md")); const isOverMdViewport = useMediaQuery(theme.breakpoints.up("md"));
@@ -51,8 +51,6 @@ export default function DashboardSidebar({
return () => {}; return () => {};
}, [expanded, theme.transitions.duration.enteringScreen]); }, [expanded, theme.transitions.duration.enteringScreen]);
const mini = !disableCollapsibleSidebar && !expanded;
const handleSetSidebarExpanded = React.useCallback( const handleSetSidebarExpanded = React.useCallback(
(newExpanded: boolean) => () => { (newExpanded: boolean) => () => {
setExpanded(newExpanded); setExpanded(newExpanded);
@@ -64,15 +62,13 @@ export default function DashboardSidebar({
if (!isOverSmViewport) { if (!isOverSmViewport) {
setExpanded(false); setExpanded(false);
} }
}, [mini, setExpanded, isOverSmViewport]); }, [expanded, setExpanded, isOverSmViewport]);
const hasDrawerTransitions = const hasDrawerTransitions = isOverSmViewport && isOverMdViewport;
isOverSmViewport && (!disableCollapsibleSidebar || isOverMdViewport);
const getDrawerContent = React.useCallback( const getDrawerContent = React.useCallback(
(viewport: "phone" | "tablet" | "desktop") => ( (viewport: "phone" | "tablet" | "desktop") => (
<React.Fragment> <React.Fragment>
<Toolbar />
<Box <Box
component="nav" component="nav"
aria-label={`${viewport.charAt(0).toUpperCase()}${viewport.slice(1)}`} aria-label={`${viewport.charAt(0).toUpperCase()}${viewport.slice(1)}`}
@@ -82,9 +78,10 @@ export default function DashboardSidebar({
flexDirection: "column", flexDirection: "column",
justifyContent: "space-between", justifyContent: "space-between",
overflow: "auto", overflow: "auto",
scrollbarGutter: mini ? "stable" : "auto", scrollbarGutter: !expanded ? "stable" : "auto",
overflowX: "hidden", overflowX: "hidden",
pt: !mini ? 0 : 2, pt: expanded ? 0 : 2,
paddingTop: 0,
...(hasDrawerTransitions ...(hasDrawerTransitions
? getDrawerSxTransitionMixin(isFullyExpanded, "padding") ? getDrawerSxTransitionMixin(isFullyExpanded, "padding")
: {}), : {}),
@@ -93,42 +90,59 @@ export default function DashboardSidebar({
<List <List
dense dense
sx={{ sx={{
padding: mini ? 0 : 0.5, padding: !expanded ? 0 : 0.5,
mb: 4, mb: 4,
width: mini ? MINI_DRAWER_WIDTH : "auto", width: !expanded ? MINI_DRAWER_WIDTH : "auto",
}} }}
> >
<DashboardSidebarPageItem <DashboardSidebarPageItem
disabled={!user.info.matrix_account_connected}
title="Messages" title="Messages"
icon={<Icon path={mdiForum} size={"1.5em"} />} icon={<Icon path={mdiForum} size={"1.5em"} />}
href="/" href="/"
mini={viewport === "desktop"}
/> />
<DashboardSidebarDividerItem /> <DashboardSidebarDividerItem />
<DashboardSidebarPageItem <DashboardSidebarPageItem
title="Matrix link" title="Matrix link"
icon={<Icon path={mdiLinkLock} size={"1.5em"} />} icon={<Icon path={mdiLinkLock} size={"1.5em"} />}
href="/matrix_link" href="/matrix_link"
mini={viewport === "desktop"}
/> />
<DashboardSidebarPageItem <DashboardSidebarPageItem
title="API tokens" title="API tokens"
icon={<Icon path={mdiKeyVariant} size={"1.5em"} />} icon={<Icon path={mdiKeyVariant} size={"1.5em"} />}
href="/tokens" href="/tokens"
mini={viewport === "desktop"}
/> />
<DashboardSidebarPageItem <DashboardSidebarPageItem
disabled={!user.info.matrix_account_connected}
title="WS Debug" title="WS Debug"
icon={<Icon path={mdiBug} size={"1.5em"} />} icon={<Icon path={mdiBug} size={"1.5em"} />}
href="/wsdebug" href="/wsdebug"
mini={viewport === "desktop"}
/> />
</List> </List>
</Box> </Box>
</React.Fragment> </React.Fragment>
), ),
[mini, hasDrawerTransitions, isFullyExpanded] [
expanded,
hasDrawerTransitions,
isFullyExpanded,
user.info.matrix_account_connected,
]
); );
const getDrawerSharedSx = React.useCallback( const getDrawerSharedSx = React.useCallback(
(isTemporary: boolean) => { (isTemporary: boolean, desktop?: boolean) => {
const drawerWidth = mini ? MINI_DRAWER_WIDTH : DRAWER_WIDTH; const drawerWidth = desktop
? expanded
? MINI_DRAWER_WIDTH
: 0
: !expanded
? MINI_DRAWER_WIDTH
: DRAWER_WIDTH;
return { return {
displayPrint: "none", displayPrint: "none",
@@ -145,17 +159,16 @@ export default function DashboardSidebar({
}, },
}; };
}, },
[expanded, mini] [expanded, !expanded]
); );
const sidebarContextValue = React.useMemo(() => { const sidebarContextValue = React.useMemo(() => {
return { return {
onPageItemClick: handlePageItemClick, onPageItemClick: handlePageItemClick,
mini,
fullyExpanded: isFullyExpanded, fullyExpanded: isFullyExpanded,
hasDrawerTransitions, hasDrawerTransitions,
}; };
}, [handlePageItemClick, mini, isFullyExpanded, hasDrawerTransitions]); }, [handlePageItemClick, !expanded, isFullyExpanded, hasDrawerTransitions]);
return ( return (
<DashboardSidebarContext.Provider value={sidebarContextValue}> <DashboardSidebarContext.Provider value={sidebarContextValue}>
@@ -170,7 +183,7 @@ export default function DashboardSidebar({
sx={{ sx={{
display: { display: {
xs: "block", xs: "block",
sm: disableCollapsibleSidebar ? "block" : "none", sm: "none",
md: "none", md: "none",
}, },
...getDrawerSharedSx(true), ...getDrawerSharedSx(true),
@@ -183,7 +196,7 @@ export default function DashboardSidebar({
sx={{ sx={{
display: { display: {
xs: "none", xs: "none",
sm: disableCollapsibleSidebar ? "none" : "block", sm: "block",
md: "none", md: "none",
}, },
...getDrawerSharedSx(false), ...getDrawerSharedSx(false),
@@ -195,7 +208,7 @@ export default function DashboardSidebar({
variant="permanent" variant="permanent"
sx={{ sx={{
display: { xs: "none", md: "block" }, display: { xs: "none", md: "block" },
...getDrawerSharedSx(false), ...getDrawerSharedSx(false, true),
}} }}
> >
{getDrawerContent("desktop")} {getDrawerContent("desktop")}

View File

@@ -2,7 +2,6 @@ import * as React from "react";
const DashboardSidebarContext = React.createContext<{ const DashboardSidebarContext = React.createContext<{
onPageItemClick: () => void; onPageItemClick: () => void;
mini: boolean;
fullyExpanded: boolean; fullyExpanded: boolean;
hasDrawerTransitions: boolean; hasDrawerTransitions: boolean;
} | null>(null); } | null>(null);

View File

@@ -17,6 +17,7 @@ export interface DashboardSidebarPageItemProps {
href: string; href: string;
action?: React.ReactNode; action?: React.ReactNode;
disabled?: boolean; disabled?: boolean;
mini?: boolean;
} }
export default function DashboardSidebarPageItem({ export default function DashboardSidebarPageItem({
@@ -25,6 +26,7 @@ export default function DashboardSidebarPageItem({
href, href,
action, action,
disabled = false, disabled = false,
mini = false,
}: DashboardSidebarPageItemProps) { }: DashboardSidebarPageItemProps) {
const { pathname } = useLocation(); const { pathname } = useLocation();
@@ -32,11 +34,7 @@ export default function DashboardSidebarPageItem({
if (!sidebarContext) { if (!sidebarContext) {
throw new Error("Sidebar context was used without a provider."); throw new Error("Sidebar context was used without a provider.");
} }
const { const { onPageItemClick, fullyExpanded = true } = sidebarContext;
onPageItemClick,
mini = false,
fullyExpanded = true,
} = sidebarContext;
const hasExternalHref = href const hasExternalHref = href
? href.startsWith("http://") || href.startsWith("https://") ? href.startsWith("http://") || href.startsWith("https://")

View File

@@ -0,0 +1,23 @@
import { Checkbox, FormControlLabel } from "@mui/material";
export function CheckboxInput(p: {
editable: boolean;
label: string;
checked: boolean | undefined;
onValueChange: (v: boolean) => void;
}): React.ReactElement {
return (
<FormControlLabel
control={
<Checkbox
disabled={!p.editable}
checked={p.checked}
onChange={(e) => {
p.onValueChange(e.target.checked);
}}
/>
}
label={p.label}
/>
);
}

View File

@@ -0,0 +1,49 @@
import { DateField } from "@mui/x-date-pickers";
import dayjs from "dayjs";
import { TextInput } from "./TextInput";
export function DateInput(p: {
editable?: boolean;
required?: boolean;
label: string;
value: number | undefined | null;
checkValue?: (s: number) => boolean;
disableFuture?: boolean;
disablePast?: boolean;
onChange: (newVal: number | undefined | null) => void;
}): React.ReactElement {
const date = p.value ? dayjs.unix(p.value) : undefined;
const error = p.value && p.checkValue && !p.checkValue(p.value);
if (!p.editable)
return (
<TextInput
{...p}
checkValue={undefined}
value={date !== undefined ? date.format("DD/MM/YYYY") : undefined}
/>
);
return (
<DateField
clearable
value={date}
onChange={(v) => p.onChange(v?.unix())}
slotProps={{
textField: {
fullWidth: true,
label: p.label,
variant: "standard",
},
inputAdornment: {
variant: "standard",
},
}}
disableFuture={p.disableFuture}
disablePast={p.disablePast}
error={error === true}
format="DD/MM/YYYY"
/>
);
}

View File

@@ -0,0 +1,26 @@
import { isIPNetworkValid } from "../../utils/FormUtils";
import { TextInput } from "./TextInput";
function rebuildNetworksList(val?: string): string[] | undefined {
if (!val || val.trim() === "") return undefined;
return val.split(",").map((v) => v.trim());
}
export function NetworksInput(p: {
editable?: boolean;
label: string;
value?: string[];
onChange: (n: string[] | undefined) => void;
}): React.ReactElement {
const textValue = (p.value ?? []).join(", ").trim();
return (
<TextInput
{...p}
type="string"
value={textValue}
onValueChange={(i) => p.onChange(rebuildNetworksList(i))}
checkValue={(v) => (rebuildNetworksList(v) ?? []).every(isIPNetworkValid)}
/>
);
}

View File

@@ -0,0 +1,65 @@
import { TextField, type TextFieldVariants } from "@mui/material";
import type { LenConstraint } from "../../api/ServerApi";
/**
* Text input
*/
export function TextInput(p: {
label?: string;
editable?: boolean;
required?: boolean;
value?: string;
onValueChange?: (newVal: string | undefined) => void;
size?: LenConstraint;
checkValue?: (s: string) => boolean;
multiline?: boolean;
minRows?: number;
maxRows?: number;
placeholder?: string;
type?: React.HTMLInputTypeAttribute;
style?: React.CSSProperties;
helperText?: string;
variant?: TextFieldVariants;
}): React.ReactElement {
if (!p.editable && (p.value ?? "") === "") return <></>;
let valueError = undefined;
if (p.value && p.value.length > 0) {
if (p.size?.min && p.type !== "number" && p.value.length < p.size.min)
valueError = `Please specify at least ${p.size.min} characters !`;
if (p.checkValue && !p.checkValue(p.value)) valueError = "Invalid value!";
if (
p.type === "number" &&
p.size &&
(Number(p.value) > p.size.max || Number(p.value) < p.size.min)
)
valueError = "Invalid size range!";
}
return (
<TextField
label={p.label}
required={p.required}
value={p.value ?? ""}
onChange={(e) =>
p.onValueChange?.(
e.target.value.length === 0 ? undefined : e.target.value
)
}
slotProps={{
input: {
readOnly: !p.editable,
type: p.type,
},
htmlInput: { maxLength: p.size?.max, placeholder: p.placeholder },
}}
variant={p.variant ?? "standard"}
style={p.style ?? { width: "100%", marginBottom: "15px" }}
multiline={p.multiline}
minRows={p.minRows}
maxRows={p.maxRows}
error={valueError !== undefined}
helperText={valueError ?? p.helperText}
/>
);
}

View File

@@ -0,0 +1,85 @@
import { Divider } from "@mui/material";
import React from "react";
import {
MatrixApiProfile,
type UsersMap,
} from "../../api/matrix/MatrixApiProfile";
import { MatrixApiRoom, type Room } from "../../api/matrix/MatrixApiRoom";
import { MatrixSyncApi } from "../../api/MatrixSyncApi";
import { AsyncWidget } from "../AsyncWidget";
import { RoomSelector } from "./RoomSelector";
import { RoomWidget } from "./RoomWidget";
import { SpaceSelector } from "./SpaceSelector";
export function MainMessageWidget(): React.ReactElement {
const [rooms, setRooms] = React.useState<Room[] | undefined>();
const [users, setUsers] = React.useState<UsersMap | undefined>();
const load = async () => {
await MatrixSyncApi.Start();
const rooms = await MatrixApiRoom.ListJoined();
setRooms(rooms);
// Get the list of users in rooms
const users = rooms.reduce((prev, r) => {
r.members.forEach((m) => prev.add(m));
return prev;
}, new Set<string>());
setUsers(await MatrixApiProfile.GetMultiple([...users]));
};
return (
<AsyncWidget
loadKey={1}
load={load}
ready={!!rooms && !!users}
errMsg="Failed to initialize messaging component!"
build={() => <_MainMessageWidget rooms={rooms!} users={users!} />}
/>
);
}
function _MainMessageWidget(p: {
rooms: Room[];
users: UsersMap;
}): React.ReactElement {
const [space, setSpace] = React.useState<string | undefined>();
const [room, setRoom] = React.useState<Room | undefined>();
const spaceRooms = React.useMemo(() => {
return p.rooms
.filter((r) => !r.is_space && (!space || r.parents.includes(space)))
.sort(
(a, b) => (b.latest_event?.time ?? 0) - (a.latest_event?.time ?? 0)
);
}, [space, p.rooms]);
return (
<div style={{ display: "flex", height: "100%" }}>
<SpaceSelector {...p} selectedSpace={space} onChange={setSpace} />
<Divider orientation="vertical" />
<RoomSelector
{...p}
rooms={spaceRooms}
currRoom={room}
onChange={setRoom}
/>
<Divider orientation="vertical" />
{room === undefined && (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
flex: 1,
}}
>
No room selected.
</div>
)}
{room && <RoomWidget {...p} room={room} />}
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { Avatar } from "@mui/material";
import { MatrixApiMedia } from "../../api/matrix/MatrixApiMedia";
import type { UsersMap } from "../../api/matrix/MatrixApiProfile";
import {
mainRoomMember,
roomName,
type Room,
} from "../../api/matrix/MatrixApiRoom";
import { useUserInfo } from "../dashboard/BaseAuthenticatedPage";
export function RoomIcon(p: {
room: Room;
users: UsersMap;
}): React.ReactElement {
const user = useUserInfo();
let url = p.room.avatar;
if (!url) {
const member = mainRoomMember(user.info, p.room);
if (member) url = p.users.get(member)?.avatar;
}
const name = roomName(user.info, p.room, p.users);
return (
<Avatar
variant={p.room.is_space ? "square" : undefined}
src={url ? MatrixApiMedia.MediaURL(url, true) : undefined}
>
{name.slice(0, 1)}
</Avatar>
);
}

View File

@@ -0,0 +1,80 @@
import {
Chip,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
} from "@mui/material";
import type { UsersMap } from "../../api/matrix/MatrixApiProfile";
import { roomName, type Room } from "../../api/matrix/MatrixApiRoom";
import { useUserInfo } from "../dashboard/BaseAuthenticatedPage";
import { RoomIcon } from "./RoomIcon";
const ROOM_SELECTOR_WIDTH = "300px";
export function RoomSelector(p: {
users: UsersMap;
rooms: Room[];
currRoom?: Room;
onChange: (r: Room) => void;
}): React.ReactElement {
const user = useUserInfo();
if (p.rooms.length === 0)
return (
<div
style={{
width: ROOM_SELECTOR_WIDTH,
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
No room to display.
</div>
);
return (
<List
style={{
width: ROOM_SELECTOR_WIDTH,
}}
>
{p.rooms.map((r) => (
<ListItem
key={r.id}
secondaryAction={
r.number_unread_messages === 0 ? undefined : (
<Chip color="error" label={r.number_unread_messages} />
)
}
disablePadding
>
<ListItemButton
role={undefined}
onClick={() => p.onChange(r)}
dense
selected={p.currRoom?.id === r.id}
>
<ListItemIcon>
<RoomIcon room={r} {...p} />
</ListItemIcon>
<ListItemText
primary={
<span
style={{
fontWeight:
r.number_unread_messages > 0 ? "bold" : undefined,
}}
>
{roomName(user.info, r, p.users)}
</span>
}
/>
</ListItemButton>
</ListItem>
))}
</List>
);
}

View File

@@ -0,0 +1,30 @@
import React from "react";
import { MatrixApiEvent } from "../../api/matrix/MatrixApiEvent";
import type { UsersMap } from "../../api/matrix/MatrixApiProfile";
import type { Room } from "../../api/matrix/MatrixApiRoom";
import { RoomEventsManager } from "../../utils/RoomEventsManager";
import { AsyncWidget } from "../AsyncWidget";
export function RoomWidget(p: {
room: Room;
users: UsersMap;
}): React.ReactElement {
const [roomMgr, setRoomMgr] = React.useState<undefined | RoomEventsManager>();
const load = async () => {
setRoomMgr(undefined);
const messages = await MatrixApiEvent.GetRoomEvents(p.room);
const mgr = new RoomEventsManager(p.room, messages);
setRoomMgr(mgr);
};
return (
<AsyncWidget
loadKey={p.room.id}
ready={!!roomMgr}
load={load}
errMsg="Failed to load room!"
build={() => <>room</>}
/>
);
}

View File

@@ -0,0 +1,53 @@
import HomeIcon from "@mui/icons-material/Home";
import { Button } from "@mui/material";
import React from "react";
import type { UsersMap } from "../../api/matrix/MatrixApiProfile";
import type { Room } from "../../api/matrix/MatrixApiRoom";
import { RoomIcon } from "./RoomIcon";
export function SpaceSelector(p: {
rooms: Room[];
users: UsersMap;
selectedSpace?: string;
onChange: (space?: string) => void;
}): React.ReactElement {
const spaces = React.useMemo(
() => p.rooms.filter((r) => r.is_space),
[p.rooms]
);
return (
<div style={{ display: "flex", flexDirection: "column" }}>
<SpaceButton
icon={<HomeIcon />}
onClick={() => p.onChange()}
selected={p.selectedSpace === undefined}
/>
{spaces.map((s) => (
<SpaceButton
key={s.id}
icon={<RoomIcon room={s} {...p} />}
onClick={() => p.onChange(s.id)}
selected={p.selectedSpace === s.id}
/>
))}
</div>
);
}
function SpaceButton(p: {
selected?: boolean;
icon: React.ReactElement;
onClick: () => void;
}): React.ReactElement {
return (
<Button
variant={p.selected ? "contained" : "text"}
style={{ margin: "2px 5px", padding: "25px 10px", fontSize: "200%" }}
onClick={p.onClick}
>
{p.icon}
</Button>
);
}