Compare commits
57 Commits
37fad9ff55
...
migrate-to
| Author | SHA1 | Date | |
|---|---|---|---|
| 5eab7c3e4f | |||
| a7bfd713c3 | |||
| 4be661d999 | |||
| 1f4e374e66 | |||
| cce9b3de5d | |||
| 820b095be0 | |||
| 0a37688116 | |||
| 4d72644a31 | |||
| 0a395b0d26 | |||
| 639cc6c737 | |||
| bf119a34fb | |||
| 7562a7fc61 | |||
| d23190f9d2 | |||
| 35b53fee5c | |||
| 934e6a4cc1 | |||
| b744265242 | |||
| e8ce97eea0 | |||
| ecbe4885c1 | |||
| 1385afc974 | |||
| 8d2cea5f82 | |||
| 751e3b8654 | |||
| 24f06a78a9 | |||
| 6b70842b61 | |||
| 7203671b18 | |||
| 055ab3759c | |||
| 3ecfc6b470 | |||
| a1b22699e9 | |||
| 0d8905d842 | |||
| 564e606ac7 | |||
| 7b691962a0 | |||
| 1e00d24a8b | |||
| cfdf98b47a | |||
| 75b6b224bc | |||
| 07f6544a4a | |||
| 5bf7c7f8df | |||
| 79d4482ea4 | |||
| c9b703bea3 | |||
| 5c13cffe08 | |||
| b5832df746 | |||
| 02e5575892 | |||
| 2683268042 | |||
| 72aaf7b082 | |||
| c8a48488fc | |||
| 3b7b368e13 | |||
| 5ca126eef7 | |||
| 7c78eb541e | |||
| 8fdf1d57eb | |||
| b10ec9ce92 | |||
| 7925785c8b | |||
| 84c90ea033 | |||
| a23d671376 | |||
| 4a72411d65 | |||
| 70a246355b | |||
| 8bbbe7022f | |||
| 1ba5372468 | |||
| 1438e2de0e | |||
| 1eaec9d319 |
62
matrixgw_backend/Cargo.lock
generated
62
matrixgw_backend/Cargo.lock
generated
@@ -241,6 +241,20 @@ dependencies = [
|
||||
"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]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
@@ -763,6 +777,17 @@ version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
@@ -2343,6 +2368,15 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "infer"
|
||||
version = "0.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
@@ -2530,6 +2564,29 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
@@ -3003,6 +3060,7 @@ dependencies = [
|
||||
"actix-remote-ip",
|
||||
"actix-session",
|
||||
"actix-web",
|
||||
"actix-ws",
|
||||
"anyhow",
|
||||
"base16ct 0.3.0",
|
||||
"bytes",
|
||||
@@ -3010,8 +3068,10 @@ dependencies = [
|
||||
"env_logger",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"infer",
|
||||
"ipnet",
|
||||
"jwt-simple",
|
||||
"lazy-regex",
|
||||
"lazy_static",
|
||||
"light-openid",
|
||||
"log",
|
||||
@@ -3020,11 +3080,11 @@ dependencies = [
|
||||
"ractor",
|
||||
"rand 0.9.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"url",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ actix-cors = "0.7.1"
|
||||
light-openid = "1.0.4"
|
||||
bytes = "1.10.1"
|
||||
sha2 = "0.10.9"
|
||||
urlencoding = "2.1.3"
|
||||
base16ct = { version = "0.3.0", features = ["alloc"] }
|
||||
futures-util = "0.3.31"
|
||||
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"
|
||||
hex = "0.4.3"
|
||||
mailchecker = "6.0.19"
|
||||
matrix-sdk = "0.14.0"
|
||||
matrix-sdk = { version = "0.14.0" }
|
||||
url = "2.5.7"
|
||||
ractor = "0.15.9"
|
||||
serde_json = "1.0.145"
|
||||
lazy-regex = "3.4.2"
|
||||
actix-ws = "0.3.0"
|
||||
infer = "0.19.0"
|
||||
@@ -2,11 +2,13 @@ use clap::Parser;
|
||||
use jwt_simple::algorithms::HS256Key;
|
||||
use jwt_simple::prelude::{Clock, Duration, JWTClaims, MACLike};
|
||||
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 std::ops::Add;
|
||||
use std::os::unix::prelude::CommandExt;
|
||||
use std::process::Command;
|
||||
use std::str::FromStr;
|
||||
|
||||
/// cURL wrapper to query MatrixGW
|
||||
#[derive(Parser, Debug)]
|
||||
@@ -20,9 +22,9 @@ struct Args {
|
||||
#[arg(short('i'), long, env)]
|
||||
token_id: String,
|
||||
|
||||
/// User ID
|
||||
/// User email
|
||||
#[arg(short('u'), long, env)]
|
||||
user_id: String,
|
||||
user_mail: String,
|
||||
|
||||
/// Token secret
|
||||
#[arg(short('t'), long, env)]
|
||||
@@ -69,11 +71,14 @@ fn main() {
|
||||
};
|
||||
|
||||
let jwt = key
|
||||
.with_key_id(&format!(
|
||||
"{}#{}",
|
||||
urlencoding::encode(&args.user_id),
|
||||
urlencoding::encode(&args.token_id)
|
||||
))
|
||||
.with_key_id(
|
||||
&MatrixJWTKID {
|
||||
user_email: UserEmail(args.user_mail),
|
||||
id: APITokenID::from_str(args.token_id.as_str())
|
||||
.expect("Failed to decode token ID!"),
|
||||
}
|
||||
.to_string(),
|
||||
)
|
||||
.authenticate(claims)
|
||||
.expect("Failed to sign JWT!");
|
||||
|
||||
|
||||
@@ -220,6 +220,11 @@ impl AppConfig {
|
||||
pub fn user_matrix_passphrase_path(&self, mail: &UserEmail) -> PathBuf {
|
||||
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)]
|
||||
|
||||
37
matrixgw_backend/src/broadcast_messages.rs
Normal file
37
matrixgw_backend/src/broadcast_messages.rs
Normal 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 },
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
use std::time::Duration;
|
||||
|
||||
/// Auth header
|
||||
pub const API_AUTH_HEADER: &str = "x-client-auth";
|
||||
|
||||
/// Max token validity, in seconds
|
||||
pub const API_TOKEN_JWT_MAX_DURATION: u64 = 15 * 60;
|
||||
|
||||
/// Length of generated tokens
|
||||
pub const TOKENS_LEN: usize = 50;
|
||||
|
||||
/// Session-specific constants
|
||||
pub mod sessions {
|
||||
/// OpenID auth session state key
|
||||
@@ -13,3 +18,11 @@ pub mod sessions {
|
||||
/// Authenticated ID
|
||||
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);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use crate::app_config::AppConfig;
|
||||
use crate::broadcast_messages::BroadcastSender;
|
||||
use crate::controllers::{HttpFailure, HttpResult};
|
||||
use crate::extractors::auth_extractor::{AuthExtractor, AuthenticatedMethod};
|
||||
use crate::extractors::matrix_client_extractor::MatrixClientExtractor;
|
||||
use crate::extractors::session_extractor::MatrixGWSession;
|
||||
use crate::users::{ExtendedUserInfo, User, UserEmail};
|
||||
use crate::users::{User, UserEmail};
|
||||
use actix_remote_ip::RemoteIP;
|
||||
use actix_web::{HttpResponse, web};
|
||||
use light_openid::primitives::OpenIDConfig;
|
||||
@@ -107,19 +109,23 @@ pub async fn finish_oidc(
|
||||
}
|
||||
|
||||
/// Get current user information
|
||||
pub async fn auth_info(auth: AuthExtractor) -> HttpResult {
|
||||
Ok(HttpResponse::Ok().json(ExtendedUserInfo::from_user(auth.user).await?))
|
||||
pub async fn auth_info(client: MatrixClientExtractor) -> HttpResult {
|
||||
Ok(HttpResponse::Ok().json(client.to_extended_user_info().await?))
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
AuthenticatedMethod::Cookie => {
|
||||
session.unset_current_user()?;
|
||||
}
|
||||
|
||||
AuthenticatedMethod::Token(token) => {
|
||||
token.delete(&auth.user.email).await?;
|
||||
token.delete(&auth.user.email, &tx).await?;
|
||||
}
|
||||
|
||||
AuthenticatedMethod::Dev => {
|
||||
|
||||
@@ -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}"))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
4
matrixgw_backend/src/controllers/matrix/mod.rs
Normal file
4
matrixgw_backend/src/controllers/matrix/mod.rs
Normal 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;
|
||||
@@ -1,6 +1,10 @@
|
||||
use crate::controllers::HttpResult;
|
||||
use crate::extractors::auth_extractor::AuthExtractor;
|
||||
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)]
|
||||
struct StartAuthResponse {
|
||||
@@ -12,3 +16,44 @@ pub async fn start_auth(client: MatrixClientExtractor) -> HttpResult {
|
||||
let url = client.client.initiate_login().await?.to_string();
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -3,8 +3,12 @@ use actix_web::{HttpResponse, ResponseError};
|
||||
use std::error::Error;
|
||||
|
||||
pub mod auth_controller;
|
||||
pub mod matrix;
|
||||
pub mod matrix_link_controller;
|
||||
pub mod matrix_sync_thread_controller;
|
||||
pub mod server_controller;
|
||||
pub mod tokens_controller;
|
||||
pub mod ws_controller;
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum HttpFailure {
|
||||
@@ -16,6 +20,10 @@ pub enum HttpFailure {
|
||||
OpenID(Box<dyn Error>),
|
||||
#[error("an unspecified internal error occurred: {0}")]
|
||||
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 {
|
||||
@@ -28,7 +36,9 @@ impl ResponseError for HttpFailure {
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
53
matrixgw_backend/src/controllers/tokens_controller.rs
Normal file
53
matrixgw_backend/src/controllers/tokens_controller.rs
Normal 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())
|
||||
}
|
||||
252
matrixgw_backend/src/controllers/ws_controller.rs
Normal file
252
matrixgw_backend/src/controllers/ws_controller.rs
Normal 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);
|
||||
}
|
||||
@@ -11,6 +11,8 @@ use anyhow::Context;
|
||||
use bytes::Bytes;
|
||||
use jwt_simple::common::VerificationOptions;
|
||||
use jwt_simple::prelude::{Duration, HS256Key, MACLike};
|
||||
use jwt_simple::reexports::serde_json;
|
||||
use serde::de::DeserializeOwned;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fmt::Display;
|
||||
use std::net::IpAddr;
|
||||
@@ -26,12 +28,38 @@ pub enum AuthenticatedMethod {
|
||||
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 user: User,
|
||||
pub method: AuthenticatedMethod,
|
||||
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)]
|
||||
pub struct MatrixJWTKID {
|
||||
pub user_email: UserEmail,
|
||||
@@ -144,8 +172,9 @@ impl AuthExtractor {
|
||||
}
|
||||
|
||||
// Check IP restriction
|
||||
if let Some(net) = token.network
|
||||
&& !net.contains(&remote_ip)
|
||||
if let Some(nets) = &token.base.networks
|
||||
&& !nets.is_empty()
|
||||
&& !nets.iter().any(|n| n.contains(&remote_ip))
|
||||
{
|
||||
log::error!(
|
||||
"Trying to use token {:?} from unauthorized IP address: {remote_ip:?}",
|
||||
@@ -157,7 +186,7 @@ impl AuthExtractor {
|
||||
}
|
||||
|
||||
// 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(
|
||||
"Read only token cannot perform write operations!",
|
||||
));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::extractors::auth_extractor::AuthExtractor;
|
||||
use crate::matrix_connection::matrix_client::MatrixClient;
|
||||
use crate::matrix_connection::matrix_manager::MatrixManagerMsg;
|
||||
use crate::users::ExtendedUserInfo;
|
||||
use actix_web::dev::Payload;
|
||||
use actix_web::{FromRequest, HttpRequest, web};
|
||||
use ractor::ActorRef;
|
||||
@@ -10,6 +11,18 @@ pub struct MatrixClientExtractor {
|
||||
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 {
|
||||
type Error = actix_web::Error;
|
||||
type Future = futures_util::future::LocalBoxFuture<'static, Result<Self, Self::Error>>;
|
||||
@@ -27,9 +40,13 @@ impl FromRequest for MatrixClientExtractor {
|
||||
matrix_manager_actor,
|
||||
MatrixManagerMsg::GetClient,
|
||||
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 })
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod app_config;
|
||||
pub mod broadcast_messages;
|
||||
pub mod constants;
|
||||
pub mod controllers;
|
||||
pub mod extractors;
|
||||
|
||||
@@ -7,8 +7,16 @@ use actix_web::cookie::Key;
|
||||
use actix_web::middleware::Logger;
|
||||
use actix_web::{App, HttpServer, web};
|
||||
use matrixgw_backend::app_config::AppConfig;
|
||||
use matrixgw_backend::broadcast_messages::BroadcastMessage;
|
||||
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::users::User;
|
||||
use ractor::Actor;
|
||||
@@ -24,6 +32,8 @@ async fn main() -> std::io::Result<()> {
|
||||
.await
|
||||
.expect("Failed to connect to Redis!");
|
||||
|
||||
let (ws_tx, _) = tokio::sync::broadcast::channel::<BroadcastMessage>(16);
|
||||
|
||||
// Auto create default account, if requested
|
||||
if let Some(mail) = &AppConfig::get().unsecure_auto_login_email() {
|
||||
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(
|
||||
Some("matrix-clients-manager".to_string()),
|
||||
MatrixManagerActor,
|
||||
(),
|
||||
ws_tx.clone(),
|
||||
)
|
||||
.await
|
||||
.expect("Failed to start Matrix manager actor!");
|
||||
@@ -55,7 +65,7 @@ async fn main() -> std::io::Result<()> {
|
||||
|
||||
let cors = Cors::default()
|
||||
.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)
|
||||
.allow_any_header()
|
||||
.supports_credentials()
|
||||
@@ -69,6 +79,7 @@ async fn main() -> std::io::Result<()> {
|
||||
.app_data(web::Data::new(RemoteIPConfig {
|
||||
proxy: AppConfig::get().proxy_ip.clone(),
|
||||
}))
|
||||
.app_data(web::Data::new(ws_tx.clone()))
|
||||
// Server controller
|
||||
.route("/robots.txt", web::get().to(server_controller::robots_txt))
|
||||
.route(
|
||||
@@ -94,6 +105,91 @@ async fn main() -> std::io::Result<()> {
|
||||
"/api/matrix_link/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)
|
||||
.bind(&AppConfig::get().listen_address)?
|
||||
|
||||
@@ -1,15 +1,50 @@
|
||||
use crate::app_config::AppConfig;
|
||||
use crate::matrix_connection::matrix_manager::MatrixManagerMsg;
|
||||
use crate::users::UserEmail;
|
||||
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::{
|
||||
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::{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;
|
||||
|
||||
/// 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
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
enum MatrixClientError {
|
||||
#[error("Failed to destroy previous client data! {0}")]
|
||||
DestroyPreviousData(Box<MatrixClientError>),
|
||||
#[error("Failed to create Matrix database storage directory! {0}")]
|
||||
CreateMatrixDbDir(std::io::Error),
|
||||
#[error("Failed to create database passphrase! {0}")]
|
||||
@@ -18,27 +53,61 @@ enum MatrixClientError {
|
||||
ReadDbPassphrase(std::io::Error),
|
||||
#[error("Failed to build Matrix client! {0}")]
|
||||
BuildMatrixClient(ClientBuildError),
|
||||
#[error("Failed to clear Matrix session file! {0}")]
|
||||
ClearMatrixSessionFile(std::io::Error),
|
||||
#[error("Failed to clear Matrix database storage directory! {0}")]
|
||||
ClearMatrixDbDir(std::io::Error),
|
||||
#[error("Failed to remove database passphrase! {0}")]
|
||||
ClearDbPassphrase(std::io::Error),
|
||||
#[error("Failed to fetch server metadata! {0}")]
|
||||
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}")]
|
||||
ParseAuthRedirectURL(url::ParseError),
|
||||
#[error("Failed to build auth request! {0}")]
|
||||
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)]
|
||||
pub struct MatrixClient {
|
||||
manager: ActorRef<MatrixManagerMsg>,
|
||||
pub email: UserEmail,
|
||||
pub client: Client,
|
||||
}
|
||||
|
||||
impl MatrixClient {
|
||||
/// 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);
|
||||
std::fs::create_dir_all(&db_path).map_err(MatrixClientError::CreateMatrixDbDir)?;
|
||||
|
||||
@@ -52,7 +121,7 @@ impl MatrixClient {
|
||||
.map_err(MatrixClientError::ReadDbPassphrase)?;
|
||||
|
||||
let client = Client::builder()
|
||||
.server_name_or_homeserver_url(&AppConfig::get().matrix_homeserver)
|
||||
.homeserver_url(&AppConfig::get().matrix_homeserver)
|
||||
// Automatically refresh tokens if needed
|
||||
.handle_refresh_tokens()
|
||||
.sqlite_store(&db_path, Some(&passphrase))
|
||||
@@ -60,38 +129,77 @@ impl MatrixClient {
|
||||
.await
|
||||
.map_err(MatrixClientError::BuildMatrixClient)?;
|
||||
|
||||
let client = Self {
|
||||
manager,
|
||||
email: email.clone(),
|
||||
client,
|
||||
};
|
||||
|
||||
// Check metadata
|
||||
let server_metadata = client
|
||||
.oauth()
|
||||
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)?;
|
||||
|
||||
// TODO : restore client if client already existed
|
||||
|
||||
Ok(Self {
|
||||
email: email.clone(),
|
||||
client,
|
||||
// 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?;
|
||||
}
|
||||
|
||||
/// Destroy this Matrix client instance
|
||||
pub fn destroy(&self) -> anyhow::Result<()> {
|
||||
let db_path = AppConfig::get().user_matrix_db_path(&self.email);
|
||||
if db_path.is_file() {
|
||||
// Automatically save session when token gets refreshed
|
||||
client.setup_background_session_save().await;
|
||||
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
/// Destroy Matrix client related data
|
||||
fn destroy_data(email: &UserEmail) -> anyhow::Result<(), Box<MatrixClientError>> {
|
||||
let session_path = AppConfig::get().user_matrix_session_file_path(email);
|
||||
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)?;
|
||||
}
|
||||
|
||||
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() {
|
||||
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> {
|
||||
let oauth = self.client.oauth();
|
||||
|
||||
@@ -112,4 +220,182 @@ impl MatrixClient {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
use crate::broadcast_messages::{BroadcastMessage, BroadcastSender};
|
||||
use crate::matrix_connection::matrix_client::MatrixClient;
|
||||
use crate::matrix_connection::sync_thread::{MatrixSyncTaskID, start_sync_thread};
|
||||
use crate::users::UserEmail;
|
||||
use ractor::{Actor, ActorProcessingErr, ActorRef, RpcReplyPort};
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub struct MatrixManagerState {
|
||||
pub broadcast_sender: BroadcastSender,
|
||||
pub clients: HashMap<UserEmail, MatrixClient>,
|
||||
pub running_sync_threads: HashMap<UserEmail, MatrixSyncTaskID>,
|
||||
}
|
||||
|
||||
pub enum MatrixManagerMsg {
|
||||
GetClient(UserEmail, RpcReplyPort<anyhow::Result<MatrixClient>>),
|
||||
DisconnectClient(UserEmail),
|
||||
StartSyncThread(UserEmail),
|
||||
StopSyncThread(UserEmail),
|
||||
SyncThreadGetStatus(UserEmail, RpcReplyPort<bool>),
|
||||
SyncThreadTerminated(UserEmail, MatrixSyncTaskID),
|
||||
}
|
||||
|
||||
pub struct MatrixManagerActor;
|
||||
@@ -16,21 +25,32 @@ pub struct MatrixManagerActor;
|
||||
impl Actor for MatrixManagerActor {
|
||||
type Msg = MatrixManagerMsg;
|
||||
type State = MatrixManagerState;
|
||||
type Arguments = ();
|
||||
type Arguments = BroadcastSender;
|
||||
|
||||
async fn pre_start(
|
||||
&self,
|
||||
_myself: ActorRef<Self::Msg>,
|
||||
_args: Self::Arguments,
|
||||
args: Self::Arguments,
|
||||
) -> Result<Self::State, ActorProcessingErr> {
|
||||
Ok(MatrixManagerState {
|
||||
broadcast_sender: args,
|
||||
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(
|
||||
&self,
|
||||
_myself: ActorRef<Self::Msg>,
|
||||
myself: ActorRef<Self::Msg>,
|
||||
message: Self::Msg,
|
||||
state: &mut Self::State,
|
||||
) -> Result<(), ActorProcessingErr> {
|
||||
@@ -41,7 +61,7 @@ impl Actor for MatrixManagerActor {
|
||||
None => {
|
||||
// Generate client if required
|
||||
log::info!("Building new client for {:?}", &email);
|
||||
match MatrixClient::build_client(&email).await {
|
||||
match MatrixClient::build_client(myself, &email).await {
|
||||
Ok(c) => {
|
||||
state.clients.insert(email.clone(), c.clone());
|
||||
Ok(c)
|
||||
@@ -56,6 +76,88 @@ impl Actor for MatrixManagerActor {
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod matrix_client;
|
||||
pub mod matrix_manager;
|
||||
pub mod sync_thread;
|
||||
|
||||
155
matrixgw_backend/src/matrix_connection/sync_thread.rs
Normal file
155
matrixgw_backend/src/matrix_connection/sync_thread.rs
Normal 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}")
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
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 anyhow::Context;
|
||||
use jwt_simple::reexports::serde_json;
|
||||
use std::cmp::min;
|
||||
use std::str::FromStr;
|
||||
@@ -13,6 +19,8 @@ enum MatrixGWUserError {
|
||||
DecodeUserMetadata(serde_json::Error),
|
||||
#[error("Failed to save user metadata: {0}")]
|
||||
SaveUserMetadata(std::io::Error),
|
||||
#[error("Failed to create API token directory: {0}")]
|
||||
CreateApiTokensDirectory(std::io::Error),
|
||||
#[error("Failed to delete API token: {0}")]
|
||||
DeleteToken(std::io::Error),
|
||||
#[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)]
|
||||
pub struct APIToken {
|
||||
/// Token unique ID
|
||||
pub id: APITokenID,
|
||||
|
||||
/// Client description
|
||||
pub description: String,
|
||||
pub struct BaseAPIToken {
|
||||
/// Token name
|
||||
pub name: String,
|
||||
|
||||
/// 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
|
||||
pub secret: String,
|
||||
@@ -120,15 +174,58 @@ pub struct APIToken {
|
||||
|
||||
/// Client last usage time
|
||||
pub last_used: u64,
|
||||
|
||||
/// Read only access
|
||||
pub read_only: bool,
|
||||
|
||||
/// Token max inactivity
|
||||
pub max_inactivity: u64,
|
||||
}
|
||||
|
||||
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
|
||||
pub async fn load(email: &UserEmail, id: &APITokenID) -> anyhow::Result<Self> {
|
||||
let token_file = AppConfig::get().user_api_token_metadata_file(email, id);
|
||||
@@ -150,20 +247,33 @@ impl APIToken {
|
||||
}
|
||||
|
||||
/// 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);
|
||||
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(())
|
||||
}
|
||||
|
||||
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 {
|
||||
(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 {
|
||||
#[serde(flatten)]
|
||||
pub user: User,
|
||||
pub matrix_account_connected: bool,
|
||||
pub matrix_user_id: Option<String>,
|
||||
}
|
||||
|
||||
impl ExtendedUserInfo {
|
||||
pub async fn from_user(user: User) -> anyhow::Result<Self> {
|
||||
Ok(Self {
|
||||
user,
|
||||
matrix_user_id: None, // TODO
|
||||
})
|
||||
}
|
||||
pub matrix_device_id: Option<String>,
|
||||
pub matrix_recovery_state: EncryptionRecoveryState,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
use sha2::{Digest, Sha256};
|
||||
use sha2::{Digest, Sha256, Sha512};
|
||||
|
||||
/// Compute SHA256sum of a given string
|
||||
pub fn sha256str(input: &str) -> String {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -1,73 +1,2 @@
|
||||
# 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...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
# MatrixGW frontend
|
||||
Built using React + TypeScript + Vite
|
||||
286
matrixgw_frontend/package-lock.json
generated
286
matrixgw_frontend/package-lock.json
generated
@@ -15,8 +15,15 @@
|
||||
"@mdi/react": "^1.6.1",
|
||||
"@mui/icons-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-dom": "^19.1.1",
|
||||
"react-json-view-lite": "^2.5.0",
|
||||
"react-router": "^7.9.5"
|
||||
},
|
||||
"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": {
|
||||
"version": "1.0.7",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
@@ -2085,6 +2296,21 @@
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"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": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -2615,12 +2841,36 @@
|
||||
"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": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
|
||||
"integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
|
||||
"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": {
|
||||
"version": "2.16.1",
|
||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
|
||||
@@ -3368,6 +3618,15 @@
|
||||
"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": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -3416,6 +3675,18 @@
|
||||
"integrity": "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==",
|
||||
"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": {
|
||||
"version": "0.18.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
||||
@@ -3464,6 +3735,12 @@
|
||||
"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": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
@@ -3858,6 +4135,15 @@
|
||||
"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": {
|
||||
"name": "rolldown-vite",
|
||||
"version": "7.1.14",
|
||||
|
||||
@@ -17,8 +17,15 @@
|
||||
"@mdi/react": "^1.6.1",
|
||||
"@mui/icons-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-dom": "^19.1.1",
|
||||
"react-json-view-lite": "^2.5.0",
|
||||
"react-router": "^7.9.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -7,11 +7,14 @@ import {
|
||||
} from "react-router";
|
||||
import { AuthApi } from "./api/AuthApi";
|
||||
import { ServerApi } from "./api/ServerApi";
|
||||
import { APITokensRoute } from "./routes/APITokensRoute";
|
||||
import { LoginRoute } from "./routes/auth/LoginRoute";
|
||||
import { OIDCCbRoute } from "./routes/auth/OIDCCbRoute";
|
||||
import { HomeRoute } from "./routes/HomeRoute";
|
||||
import { MatrixAuthCallback } from "./routes/MatrixAuthCallback";
|
||||
import { MatrixLinkRoute } from "./routes/MatrixLinkRoute";
|
||||
import { NotFoundRoute } from "./routes/NotFoundRoute";
|
||||
import { WSDebugRoute } from "./routes/WSDebugRoute";
|
||||
import { BaseLoginPage } from "./widgets/auth/BaseLoginPage";
|
||||
import BaseAuthenticatedPage from "./widgets/dashboard/BaseAuthenticatedPage";
|
||||
|
||||
@@ -39,6 +42,9 @@ export function App(): React.ReactElement {
|
||||
<Route path="*" element={<BaseAuthenticatedPage />}>
|
||||
<Route path="" element={<HomeRoute />} />
|
||||
<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>
|
||||
) : (
|
||||
|
||||
@@ -6,7 +6,10 @@ export interface UserInfo {
|
||||
time_update: number;
|
||||
name: string;
|
||||
email: string;
|
||||
matrix_account_connected: boolean;
|
||||
matrix_user_id?: string;
|
||||
matrix_device_id?: string;
|
||||
matrix_recovery_state?: "Enabled" | "Disabled" | "Unknown" | "Incomplete";
|
||||
}
|
||||
|
||||
const TokenStateKey = "auth-state";
|
||||
|
||||
@@ -12,4 +12,36 @@ export class MatrixLinkApi {
|
||||
})
|
||||
).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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
34
matrixgw_frontend/src/api/MatrixSyncApi.ts
Normal file
34
matrixgw_frontend/src/api/MatrixSyncApi.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
58
matrixgw_frontend/src/api/TokensApi.ts
Normal file
58
matrixgw_frontend/src/api/TokensApi.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
15
matrixgw_frontend/src/api/WsApi.ts
Normal file
15
matrixgw_frontend/src/api/WsApi.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
70
matrixgw_frontend/src/api/matrix/MatrixApiEvent.ts
Normal file
70
matrixgw_frontend/src/api/matrix/MatrixApiEvent.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
12
matrixgw_frontend/src/api/matrix/MatrixApiMedia.ts
Normal file
12
matrixgw_frontend/src/api/matrix/MatrixApiMedia.ts
Normal 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}`;
|
||||
}
|
||||
}
|
||||
26
matrixgw_frontend/src/api/matrix/MatrixApiProfile.ts
Normal file
26
matrixgw_frontend/src/api/matrix/MatrixApiProfile.ts
Normal 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]));
|
||||
}
|
||||
}
|
||||
55
matrixgw_frontend/src/api/matrix/MatrixApiRoom.ts
Normal file
55
matrixgw_frontend/src/api/matrix/MatrixApiRoom.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
159
matrixgw_frontend/src/dialogs/CreateTokenDialog.tsx
Normal file
159
matrixgw_frontend/src/dialogs/CreateTokenDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
matrixgw_frontend/src/dialogs/SetRecoveryKeyDialog.tsx
Normal file
73
matrixgw_frontend/src/dialogs/SetRecoveryKeyDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -17,18 +17,17 @@ const LoadingMessageContextK =
|
||||
export function LoadingMessageProvider(
|
||||
p: PropsWithChildren
|
||||
): React.ReactElement {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [open, setOpen] = React.useState(0);
|
||||
|
||||
const [message, setMessage] = React.useState("");
|
||||
|
||||
const hook: LoadingMessageContext = {
|
||||
show(message) {
|
||||
setMessage(message);
|
||||
setOpen(true);
|
||||
setOpen((v) => v + 1);
|
||||
},
|
||||
hide() {
|
||||
setMessage("");
|
||||
setOpen(false);
|
||||
setOpen((v) => v - 1);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -36,7 +35,7 @@ export function LoadingMessageProvider(
|
||||
<>
|
||||
<LoadingMessageContextK value={hook}>{p.children}</LoadingMessageContextK>
|
||||
|
||||
<Dialog open={open}>
|
||||
<Dialog open={open > 0}>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
<div
|
||||
|
||||
@@ -7,3 +7,12 @@ body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#root > div {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -3,21 +3,24 @@ import "@fontsource/roboto/400.css";
|
||||
import "@fontsource/roboto/500.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 { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import { ServerApi } from "./api/ServerApi";
|
||||
import { App } from "./App";
|
||||
import { AlertDialogProvider } from "./hooks/contexts_provider/AlertDialogProvider";
|
||||
import { ConfirmDialogProvider } from "./hooks/contexts_provider/ConfirmDialogProvider";
|
||||
import { SnackbarProvider } from "./hooks/contexts_provider/SnackbarProvider";
|
||||
import { LoadingMessageProvider } from "./hooks/contexts_provider/LoadingMessageProvider";
|
||||
import { AsyncWidget } from "./widgets/AsyncWidget";
|
||||
import { ServerApi } from "./api/ServerApi";
|
||||
import { SnackbarProvider } from "./hooks/contexts_provider/SnackbarProvider";
|
||||
import "./index.css";
|
||||
import { AppTheme } from "./theme/AppTheme";
|
||||
import { CssBaseline } from "@mui/material";
|
||||
import { AsyncWidget } from "./widgets/AsyncWidget";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="en">
|
||||
<AppTheme>
|
||||
<CssBaseline enableColorScheme />
|
||||
<AlertDialogProvider>
|
||||
@@ -37,5 +40,6 @@ createRoot(document.getElementById("root")!).render(
|
||||
</ConfirmDialogProvider>
|
||||
</AlertDialogProvider>
|
||||
</AppTheme>
|
||||
</LocalizationProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
|
||||
293
matrixgw_frontend/src/routes/APITokensRoute.tsx
Normal file
293
matrixgw_frontend/src/routes/APITokensRoute.tsx
Normal 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>
|
||||
|
||||
<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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useUserInfo } from "../widgets/dashboard/BaseAuthenticatedPage";
|
||||
import { MainMessageWidget } from "../widgets/messages/MainMessagesWidget";
|
||||
import { NotLinkedAccountMessage } from "../widgets/NotLinkedAccountMessage";
|
||||
|
||||
export function HomeRoute(): React.ReactElement {
|
||||
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 />;
|
||||
}
|
||||
|
||||
81
matrixgw_frontend/src/routes/MatrixAuthCallback.tsx
Normal file
81
matrixgw_frontend/src/routes/MatrixAuthCallback.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 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 {
|
||||
Button,
|
||||
Card,
|
||||
CardActions,
|
||||
CardContent,
|
||||
CircularProgress,
|
||||
Grid,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import React from "react";
|
||||
import { MatrixLinkApi } from "../api/MatrixLinkApi";
|
||||
import { MatrixSyncApi } from "../api/MatrixSyncApi";
|
||||
import { SetRecoveryKeyDialog } from "../dialogs/SetRecoveryKeyDialog";
|
||||
import { useAlert } from "../hooks/contexts_provider/AlertDialogProvider";
|
||||
import { useConfirm } from "../hooks/contexts_provider/ConfirmDialogProvider";
|
||||
import { useLoadingMessage } from "../hooks/contexts_provider/LoadingMessageProvider";
|
||||
import { useSnackbar } from "../hooks/contexts_provider/SnackbarProvider";
|
||||
import { useUserInfo } from "../widgets/dashboard/BaseAuthenticatedPage";
|
||||
import { MatrixGWRouteContainer } from "../widgets/MatrixGWRouteContainer";
|
||||
|
||||
@@ -17,7 +30,21 @@ export function MatrixLinkRoute(): React.ReactElement {
|
||||
const user = useUserInfo();
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -68,10 +95,32 @@ function ConnectCard(): React.ReactElement {
|
||||
}
|
||||
|
||||
function ConnectedCard(): React.ReactElement {
|
||||
const snackbar = useSnackbar();
|
||||
const confirm = useConfirm();
|
||||
const alert = useAlert();
|
||||
const loadingMessage = useLoadingMessage();
|
||||
|
||||
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 (
|
||||
<Card>
|
||||
<Card style={{ marginBottom: "10px" }}>
|
||||
<CardContent>
|
||||
<Typography variant="h5" component="div" gutterBottom>
|
||||
<i>Connected to your Matrix account</i>
|
||||
@@ -79,9 +128,17 @@ function ConnectedCard(): React.ReactElement {
|
||||
|
||||
<Typography variant="body1" gutterBottom>
|
||||
<p>
|
||||
MatrixGW is currently connected to your account with ID{" "}
|
||||
<i>{user.info.matrix_user_id}</i>.
|
||||
MatrixGW is currently connected to your account with the following
|
||||
information:
|
||||
</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>
|
||||
If you encounter issues with your Matrix account you can try to
|
||||
disconnect and connect back again.
|
||||
@@ -89,10 +146,184 @@ function ConnectedCard(): React.ReactElement {
|
||||
</Typography>
|
||||
</CardContent>
|
||||
<CardActions>
|
||||
<Button size="small" variant="outlined" startIcon={<LinkOffIcon />}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<LinkOffIcon />}
|
||||
onClick={handleDisconnect}
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
</CardActions>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
78
matrixgw_frontend/src/routes/WSDebugRoute.tsx
Normal file
78
matrixgw_frontend/src/routes/WSDebugRoute.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
matrixgw_frontend/src/utils/DateUtils.ts
Normal file
8
matrixgw_frontend/src/utils/DateUtils.ts
Normal 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);
|
||||
}
|
||||
52
matrixgw_frontend/src/utils/FormUtils.ts
Normal file
52
matrixgw_frontend/src/utils/FormUtils.ts
Normal 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;
|
||||
}
|
||||
101
matrixgw_frontend/src/utils/RoomEventsManager.ts
Normal file
101
matrixgw_frontend/src/utils/RoomEventsManager.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
29
matrixgw_frontend/src/widgets/CopyTextChip.tsx
Normal file
29
matrixgw_frontend/src/widgets/CopyTextChip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
matrixgw_frontend/src/widgets/TimeWidget.tsx
Normal file
86
matrixgw_frontend/src/widgets/TimeWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,16 @@
|
||||
import { Button } from "@mui/material";
|
||||
import Box from "@mui/material/Box";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import Toolbar from "@mui/material/Toolbar";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import * as React from "react";
|
||||
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 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 {
|
||||
info: UserInfo;
|
||||
@@ -21,12 +22,26 @@ const UserInfoContextK = React.createContext<UserInfoContext | null>(null);
|
||||
|
||||
export default function BaseAuthenticatedPage(): React.ReactElement {
|
||||
const theme = useTheme();
|
||||
const alert = useAlert();
|
||||
const loadingMessage = useLoadingMessage();
|
||||
|
||||
const [userInfo, setuserInfo] = React.useState<null | UserInfo>(null);
|
||||
const loadUserInfo = async () => {
|
||||
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 navigate = useNavigate();
|
||||
|
||||
@@ -85,24 +100,22 @@ export default function BaseAuthenticatedPage(): React.ReactElement {
|
||||
<UserInfoContextK
|
||||
value={{
|
||||
info: userInfo!,
|
||||
reloadUserInfo: loadUserInfo,
|
||||
reloadUserInfo,
|
||||
signOut,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
ref={layoutRef}
|
||||
sx={{
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
overflow: "hidden",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<DashboardHeader
|
||||
menuOpen={isNavigationExpanded}
|
||||
onToggleMenu={handleToggleHeaderMenu}
|
||||
/>
|
||||
<Box
|
||||
ref={layoutRef}
|
||||
sx={{
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<DashboardSidebar
|
||||
expanded={isNavigationExpanded}
|
||||
setExpanded={setIsNavigationExpanded}
|
||||
@@ -116,7 +129,6 @@ export default function BaseAuthenticatedPage(): React.ReactElement {
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<Toolbar sx={{ displayPrint: "none" }} />
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
|
||||
@@ -81,7 +81,11 @@ export default function DashboardHeader({
|
||||
);
|
||||
|
||||
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 } }}>
|
||||
<Stack
|
||||
direction="row"
|
||||
|
||||
@@ -3,11 +3,11 @@ import Icon from "@mdi/react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Drawer from "@mui/material/Drawer";
|
||||
import List from "@mui/material/List";
|
||||
import Toolbar from "@mui/material/Toolbar";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import type {} from "@mui/material/themeCssVarsAugmentation";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import * as React from "react";
|
||||
import { useUserInfo } from "./BaseAuthenticatedPage";
|
||||
import DashboardSidebarContext from "./DashboardSidebarContext";
|
||||
import DashboardSidebarDividerItem from "./DashboardSidebarDividerItem";
|
||||
import DashboardSidebarPageItem from "./DashboardSidebarPageItem";
|
||||
@@ -27,10 +27,10 @@ export interface DashboardSidebarProps {
|
||||
export default function DashboardSidebar({
|
||||
expanded = true,
|
||||
setExpanded,
|
||||
disableCollapsibleSidebar = false,
|
||||
container,
|
||||
}: DashboardSidebarProps) {
|
||||
const theme = useTheme();
|
||||
const user = useUserInfo();
|
||||
|
||||
const isOverSmViewport = useMediaQuery(theme.breakpoints.up("sm"));
|
||||
const isOverMdViewport = useMediaQuery(theme.breakpoints.up("md"));
|
||||
@@ -51,8 +51,6 @@ export default function DashboardSidebar({
|
||||
return () => {};
|
||||
}, [expanded, theme.transitions.duration.enteringScreen]);
|
||||
|
||||
const mini = !disableCollapsibleSidebar && !expanded;
|
||||
|
||||
const handleSetSidebarExpanded = React.useCallback(
|
||||
(newExpanded: boolean) => () => {
|
||||
setExpanded(newExpanded);
|
||||
@@ -64,15 +62,13 @@ export default function DashboardSidebar({
|
||||
if (!isOverSmViewport) {
|
||||
setExpanded(false);
|
||||
}
|
||||
}, [mini, setExpanded, isOverSmViewport]);
|
||||
}, [expanded, setExpanded, isOverSmViewport]);
|
||||
|
||||
const hasDrawerTransitions =
|
||||
isOverSmViewport && (!disableCollapsibleSidebar || isOverMdViewport);
|
||||
const hasDrawerTransitions = isOverSmViewport && isOverMdViewport;
|
||||
|
||||
const getDrawerContent = React.useCallback(
|
||||
(viewport: "phone" | "tablet" | "desktop") => (
|
||||
<React.Fragment>
|
||||
<Toolbar />
|
||||
<Box
|
||||
component="nav"
|
||||
aria-label={`${viewport.charAt(0).toUpperCase()}${viewport.slice(1)}`}
|
||||
@@ -82,9 +78,10 @@ export default function DashboardSidebar({
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-between",
|
||||
overflow: "auto",
|
||||
scrollbarGutter: mini ? "stable" : "auto",
|
||||
scrollbarGutter: !expanded ? "stable" : "auto",
|
||||
overflowX: "hidden",
|
||||
pt: !mini ? 0 : 2,
|
||||
pt: expanded ? 0 : 2,
|
||||
paddingTop: 0,
|
||||
...(hasDrawerTransitions
|
||||
? getDrawerSxTransitionMixin(isFullyExpanded, "padding")
|
||||
: {}),
|
||||
@@ -93,42 +90,59 @@ export default function DashboardSidebar({
|
||||
<List
|
||||
dense
|
||||
sx={{
|
||||
padding: mini ? 0 : 0.5,
|
||||
padding: !expanded ? 0 : 0.5,
|
||||
mb: 4,
|
||||
width: mini ? MINI_DRAWER_WIDTH : "auto",
|
||||
width: !expanded ? MINI_DRAWER_WIDTH : "auto",
|
||||
}}
|
||||
>
|
||||
<DashboardSidebarPageItem
|
||||
disabled={!user.info.matrix_account_connected}
|
||||
title="Messages"
|
||||
icon={<Icon path={mdiForum} size={"1.5em"} />}
|
||||
href="/"
|
||||
mini={viewport === "desktop"}
|
||||
/>
|
||||
<DashboardSidebarDividerItem />
|
||||
<DashboardSidebarPageItem
|
||||
title="Matrix link"
|
||||
icon={<Icon path={mdiLinkLock} size={"1.5em"} />}
|
||||
href="/matrix_link"
|
||||
mini={viewport === "desktop"}
|
||||
/>
|
||||
<DashboardSidebarPageItem
|
||||
title="API tokens"
|
||||
icon={<Icon path={mdiKeyVariant} size={"1.5em"} />}
|
||||
href="/tokens"
|
||||
mini={viewport === "desktop"}
|
||||
/>
|
||||
<DashboardSidebarPageItem
|
||||
disabled={!user.info.matrix_account_connected}
|
||||
title="WS Debug"
|
||||
icon={<Icon path={mdiBug} size={"1.5em"} />}
|
||||
href="/wsdebug"
|
||||
mini={viewport === "desktop"}
|
||||
/>
|
||||
</List>
|
||||
</Box>
|
||||
</React.Fragment>
|
||||
),
|
||||
[mini, hasDrawerTransitions, isFullyExpanded]
|
||||
[
|
||||
expanded,
|
||||
hasDrawerTransitions,
|
||||
isFullyExpanded,
|
||||
user.info.matrix_account_connected,
|
||||
]
|
||||
);
|
||||
|
||||
const getDrawerSharedSx = React.useCallback(
|
||||
(isTemporary: boolean) => {
|
||||
const drawerWidth = mini ? MINI_DRAWER_WIDTH : DRAWER_WIDTH;
|
||||
(isTemporary: boolean, desktop?: boolean) => {
|
||||
const drawerWidth = desktop
|
||||
? expanded
|
||||
? MINI_DRAWER_WIDTH
|
||||
: 0
|
||||
: !expanded
|
||||
? MINI_DRAWER_WIDTH
|
||||
: DRAWER_WIDTH;
|
||||
|
||||
return {
|
||||
displayPrint: "none",
|
||||
@@ -145,17 +159,16 @@ export default function DashboardSidebar({
|
||||
},
|
||||
};
|
||||
},
|
||||
[expanded, mini]
|
||||
[expanded, !expanded]
|
||||
);
|
||||
|
||||
const sidebarContextValue = React.useMemo(() => {
|
||||
return {
|
||||
onPageItemClick: handlePageItemClick,
|
||||
mini,
|
||||
fullyExpanded: isFullyExpanded,
|
||||
hasDrawerTransitions,
|
||||
};
|
||||
}, [handlePageItemClick, mini, isFullyExpanded, hasDrawerTransitions]);
|
||||
}, [handlePageItemClick, !expanded, isFullyExpanded, hasDrawerTransitions]);
|
||||
|
||||
return (
|
||||
<DashboardSidebarContext.Provider value={sidebarContextValue}>
|
||||
@@ -170,7 +183,7 @@ export default function DashboardSidebar({
|
||||
sx={{
|
||||
display: {
|
||||
xs: "block",
|
||||
sm: disableCollapsibleSidebar ? "block" : "none",
|
||||
sm: "none",
|
||||
md: "none",
|
||||
},
|
||||
...getDrawerSharedSx(true),
|
||||
@@ -183,7 +196,7 @@ export default function DashboardSidebar({
|
||||
sx={{
|
||||
display: {
|
||||
xs: "none",
|
||||
sm: disableCollapsibleSidebar ? "none" : "block",
|
||||
sm: "block",
|
||||
md: "none",
|
||||
},
|
||||
...getDrawerSharedSx(false),
|
||||
@@ -195,7 +208,7 @@ export default function DashboardSidebar({
|
||||
variant="permanent"
|
||||
sx={{
|
||||
display: { xs: "none", md: "block" },
|
||||
...getDrawerSharedSx(false),
|
||||
...getDrawerSharedSx(false, true),
|
||||
}}
|
||||
>
|
||||
{getDrawerContent("desktop")}
|
||||
|
||||
@@ -2,7 +2,6 @@ import * as React from "react";
|
||||
|
||||
const DashboardSidebarContext = React.createContext<{
|
||||
onPageItemClick: () => void;
|
||||
mini: boolean;
|
||||
fullyExpanded: boolean;
|
||||
hasDrawerTransitions: boolean;
|
||||
} | null>(null);
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface DashboardSidebarPageItemProps {
|
||||
href: string;
|
||||
action?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
mini?: boolean;
|
||||
}
|
||||
|
||||
export default function DashboardSidebarPageItem({
|
||||
@@ -25,6 +26,7 @@ export default function DashboardSidebarPageItem({
|
||||
href,
|
||||
action,
|
||||
disabled = false,
|
||||
mini = false,
|
||||
}: DashboardSidebarPageItemProps) {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
@@ -32,11 +34,7 @@ export default function DashboardSidebarPageItem({
|
||||
if (!sidebarContext) {
|
||||
throw new Error("Sidebar context was used without a provider.");
|
||||
}
|
||||
const {
|
||||
onPageItemClick,
|
||||
mini = false,
|
||||
fullyExpanded = true,
|
||||
} = sidebarContext;
|
||||
const { onPageItemClick, fullyExpanded = true } = sidebarContext;
|
||||
|
||||
const hasExternalHref = href
|
||||
? href.startsWith("http://") || href.startsWith("https://")
|
||||
|
||||
23
matrixgw_frontend/src/widgets/forms/CheckboxInput.tsx
Normal file
23
matrixgw_frontend/src/widgets/forms/CheckboxInput.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
49
matrixgw_frontend/src/widgets/forms/DateInput.tsx
Normal file
49
matrixgw_frontend/src/widgets/forms/DateInput.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
26
matrixgw_frontend/src/widgets/forms/NetworksInput.tsx
Normal file
26
matrixgw_frontend/src/widgets/forms/NetworksInput.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
65
matrixgw_frontend/src/widgets/forms/TextInput.tsx
Normal file
65
matrixgw_frontend/src/widgets/forms/TextInput.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
33
matrixgw_frontend/src/widgets/messages/RoomIcon.tsx
Normal file
33
matrixgw_frontend/src/widgets/messages/RoomIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
matrixgw_frontend/src/widgets/messages/RoomSelector.tsx
Normal file
80
matrixgw_frontend/src/widgets/messages/RoomSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
matrixgw_frontend/src/widgets/messages/RoomWidget.tsx
Normal file
30
matrixgw_frontend/src/widgets/messages/RoomWidget.tsx
Normal 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</>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
53
matrixgw_frontend/src/widgets/messages/SpaceSelector.tsx
Normal file
53
matrixgw_frontend/src/widgets/messages/SpaceSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user