Compare commits
216 Commits
d9c96e85f7
...
renovate/l
| Author | SHA1 | Date | |
|---|---|---|---|
| 149f3734b7 | |||
| 8f4480e555 | |||
| 06e1f60314 | |||
| a7432a4014 | |||
| be5e7eb328 | |||
| c1e703c4b4 | |||
| 071aad8147 | |||
| f4b3c0aa16 | |||
| daca7410d7 | |||
| 23074ac354 | |||
| 36bd8d0672 | |||
| 6162555702 | |||
| 951d0db0b7 | |||
| ef90aba489 | |||
| 6b39fd11bd | |||
| bf6561fa87 | |||
| e5feecc703 | |||
| 153ad14a51 | |||
| e5494e51a3 | |||
| f04ab4591b | |||
| ca2cdb2f79 | |||
| 795a12c8d0 | |||
| 79e78006fa | |||
| 18c0fbef3c | |||
| 0ac6fc4ac3 | |||
| 2aaced17d8 | |||
| 9da2a9e9b3 | |||
| 9595ff2e71 | |||
| 5bd62d7683 | |||
| 7e747b50f3 | |||
| a9a5d60edd | |||
| 89dbc252e8 | |||
| 3a6b2c6cf2 | |||
| 98c813b220 | |||
| ceb7859169 | |||
| 96e597ca59 | |||
| d9630fbc4c | |||
| 4d7db2de2a | |||
| 4c0be88570 | |||
| 2f933a247f | |||
| f6a7132d43 | |||
| 1089b5a6a6 | |||
| 28a1b5f4f0 | |||
| acf91c3f0e | |||
| fadb9e6d46 | |||
| 17bad4fcfd | |||
| b3dfc35103 | |||
| 602f663217 | |||
| 5ebfbf6aec | |||
| aad0a74ad5 | |||
| 382e24e17b | |||
| 1876c7b43d | |||
| 73af601a16 | |||
| 6247463c70 | |||
| 9425ed9a12 | |||
| 430ad85c37 | |||
| 29e50bd70c | |||
| 0e83e804d8 | |||
| bd674bfb67 | |||
| fec81ac92e | |||
| d3e25eed9e | |||
| ba5f5f2557 | |||
| 7e548ad5d1 | |||
| 7b63bb0d05 | |||
| 788018451a | |||
| 7b3a2d6a3f | |||
| 0d462f848d | |||
| 9c6c338919 | |||
| 8a4570a044 | |||
| e51fc6b4bb | |||
| 0f68d59798 | |||
| 5ad23005be | |||
| 4e096a1d49 | |||
| ac2a361b77 | |||
| 24f8d67020 | |||
| 5bcee2ea9d | |||
| 48d9444dde | |||
| bcdfe87107 | |||
| 5088699c15 | |||
| 854b474970 | |||
| 336aea463b | |||
| fe9c692e12 | |||
| b47ec37a76 | |||
| 996534c62b | |||
| 3ba6543cb4 | |||
| f087b27b53 | |||
| dfcf764a9b | |||
| fb35fca56e | |||
| f6568cf059 | |||
| bbf558bbf9 | |||
| 1090a59aaf | |||
| 30518f3ca3 | |||
| e215fe6484 | |||
| 6392c0a2c7 | |||
| 4110f4d063 | |||
| 1a5a021711 | |||
| 8b299bcf8f | |||
| ab136ef6d0 | |||
| 1fa98cf6e3 | |||
| 118b73fce9 | |||
| 95fb095205 | |||
| 3274d07635 | |||
| 6d78930b89 | |||
| 7356a66e4a | |||
| 30e63bfdb4 | |||
| e80d54d0e7 | |||
| b91b61f4f0 | |||
| 32354f79ea | |||
| 077c64be28 | |||
| dac20f60e0 | |||
| 9359dc5be0 | |||
| 849aef9343 | |||
| 196671d0fb | |||
| b93100413c | |||
| 57797e933a | |||
| 7acb0cbafa | |||
| 64985bb39e | |||
| 1f22d5c41b | |||
| a656c077bc | |||
| d10c4d1a1c | |||
| 62966473f0 | |||
| c360432911 | |||
| 123e069d18 | |||
| 4b30d67706 | |||
| 799341f77c | |||
| 6c11979ef2 | |||
| 9f0bc3303c | |||
| f5b16b6ce4 | |||
| 756780513b | |||
| 93487a5325 | |||
| 3d27279a16 | |||
| 94ce9c3c95 | |||
| 9f83a6fb66 | |||
| bda47a2770 | |||
| f0e8c799ff | |||
| b4e7cb8718 | |||
| b7378aa4dc | |||
| 2adbf146d0 | |||
| 5eab7c3e4f | |||
| a7bfd713c3 | |||
| 4be661d999 | |||
| 1f4e374e66 | |||
| cce9b3de5d | |||
| 820b095be0 | |||
| 0a37688116 | |||
| 4d72644a31 | |||
| 0a395b0d26 | |||
| 639cc6c737 | |||
| bf119a34fb | |||
| 7562a7fc61 | |||
| d23190f9d2 | |||
| 35b53fee5c | |||
| 934e6a4cc1 | |||
| b744265242 | |||
| e8ce97eea0 | |||
| ecbe4885c1 | |||
| 1385afc974 | |||
| 8d2cea5f82 | |||
| 751e3b8654 | |||
| 24f06a78a9 | |||
| 7a590e882b | |||
| 9a643ced94 | |||
| 6b70842b61 | |||
| 7203671b18 | |||
| 055ab3759c | |||
| 3ecfc6b470 | |||
| a1b22699e9 | |||
| 5f2a6478a7 | |||
| 1db929a31b | |||
| 0d8905d842 | |||
| 564e606ac7 | |||
| 7b691962a0 | |||
| 1e00d24a8b | |||
| cfdf98b47a | |||
| 75b6b224bc | |||
| 07f6544a4a | |||
| 5bf7c7f8df | |||
| 79d4482ea4 | |||
| c9b703bea3 | |||
| 5c13cffe08 | |||
| b5832df746 | |||
| 0b2c4071e8 | |||
| 61ecfc5af1 | |||
| 661793f58d | |||
| d253e73099 | |||
| f0d3d311e9 | |||
| 592203aa4a | |||
| 02e5575892 | |||
| 2683268042 | |||
| 72aaf7b082 | |||
| c8a48488fc | |||
| 3b7b368e13 | |||
| 5ca126eef7 | |||
| 7c78eb541e | |||
| 8fdf1d57eb | |||
| b10ec9ce92 | |||
| 7925785c8b | |||
| 84c90ea033 | |||
| a23d671376 | |||
| 4a72411d65 | |||
| 70a246355b | |||
| 8bbbe7022f | |||
| 1ba5372468 | |||
| aeb35029c3 | |||
| 1dc56d5ec1 | |||
| 1438e2de0e | |||
| 1eaec9d319 | |||
| 37fad9ff55 | |||
| 3dab9f41d2 | |||
| a44327ddb0 | |||
| f9fb99cdb5 | |||
| 3de26c0fff | |||
| 79b5a767f3 | |||
| fdcd565431 | |||
| 51b1ab380c | |||
| b5abddaacb |
104
.drone.yml
Normal file
104
.drone.yml
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
---
|
||||||
|
kind: pipeline
|
||||||
|
type: docker
|
||||||
|
name: default
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# Frontend
|
||||||
|
- name: web_build
|
||||||
|
image: node:23
|
||||||
|
volumes:
|
||||||
|
- name: web_app
|
||||||
|
path: /tmp/web_build
|
||||||
|
commands:
|
||||||
|
- node -v
|
||||||
|
- npm -v
|
||||||
|
- cd matrixgw_frontend
|
||||||
|
- npm install
|
||||||
|
- npm run lint
|
||||||
|
- npm run build
|
||||||
|
- mv dist /tmp/web_build
|
||||||
|
|
||||||
|
# Backend
|
||||||
|
- name: backend_fetch_deps
|
||||||
|
image: rust
|
||||||
|
volumes:
|
||||||
|
- name: rust_registry
|
||||||
|
path: /usr/local/cargo/registry
|
||||||
|
commands:
|
||||||
|
- cd matrixgw_backend
|
||||||
|
- cargo fetch
|
||||||
|
|
||||||
|
- name: backend_code_quality
|
||||||
|
image: rust
|
||||||
|
volumes:
|
||||||
|
- name: rust_registry
|
||||||
|
path: /usr/local/cargo/registry
|
||||||
|
depends_on:
|
||||||
|
- backend_fetch_deps
|
||||||
|
commands:
|
||||||
|
- cd matrixgw_backend
|
||||||
|
- rustup component add clippy
|
||||||
|
- cargo clippy -- -D warnings
|
||||||
|
- cargo clippy --example api_curl -- -D warnings
|
||||||
|
|
||||||
|
- name: backend_test
|
||||||
|
image: rust
|
||||||
|
volumes:
|
||||||
|
- name: rust_registry
|
||||||
|
path: /usr/local/cargo/registry
|
||||||
|
depends_on:
|
||||||
|
- backend_code_quality
|
||||||
|
commands:
|
||||||
|
- cd matrixgw_backend
|
||||||
|
- cargo test
|
||||||
|
|
||||||
|
- name: backend_build
|
||||||
|
image: rust
|
||||||
|
when:
|
||||||
|
event:
|
||||||
|
- tag
|
||||||
|
volumes:
|
||||||
|
- name: rust_registry
|
||||||
|
path: /usr/local/cargo/registry
|
||||||
|
- name: web_app
|
||||||
|
path: /tmp/web_build
|
||||||
|
- name: release
|
||||||
|
path: /tmp/release
|
||||||
|
depends_on:
|
||||||
|
- backend_test
|
||||||
|
- web_build
|
||||||
|
commands:
|
||||||
|
- cd matrixgw_backend
|
||||||
|
- mv /tmp/web_build/dist static
|
||||||
|
- cargo build --release
|
||||||
|
- cargo build --release --example api_curl
|
||||||
|
- ls -lah target/release/matrixgw_backend target/release/examples/api_curl
|
||||||
|
- cp target/release/matrixgw_backend target/release/examples/api_curl /tmp/release
|
||||||
|
|
||||||
|
# Release
|
||||||
|
- name: gitea_release
|
||||||
|
image: plugins/gitea-release
|
||||||
|
depends_on:
|
||||||
|
- backend_build
|
||||||
|
when:
|
||||||
|
event:
|
||||||
|
- tag
|
||||||
|
volumes:
|
||||||
|
- name: release
|
||||||
|
path: /tmp/release
|
||||||
|
environment:
|
||||||
|
PLUGIN_API_KEY:
|
||||||
|
from_secret: GITEA_API_KEY # needs permission write:repository
|
||||||
|
settings:
|
||||||
|
base_url: https://gitea.communiquons.org
|
||||||
|
files: /tmp/release/*
|
||||||
|
checksum: sha512
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- name: rust_registry
|
||||||
|
temp: {}
|
||||||
|
- name: web_app
|
||||||
|
temp: {}
|
||||||
|
- name: release
|
||||||
|
temp: {}
|
||||||
18
Makefile
Normal file
18
Makefile
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
DOCKER_TEMP_DIR=temp
|
||||||
|
|
||||||
|
all: frontend backend
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
cd matrixgw_frontend && npm run build && cd ..
|
||||||
|
rm -rf matrixgw_backend/static
|
||||||
|
mv matrixgw_frontend/dist matrixgw_backend/static
|
||||||
|
|
||||||
|
backend: frontend
|
||||||
|
cd matrixgw_backend && cargo clippy -- -D warnings && cargo build --release
|
||||||
|
|
||||||
|
backend_docker: backend
|
||||||
|
rm -rf $(DOCKER_TEMP_DIR)
|
||||||
|
mkdir $(DOCKER_TEMP_DIR)
|
||||||
|
cp matrixgw_backend/target/release/matrixgw_backend $(DOCKER_TEMP_DIR)
|
||||||
|
docker build -t pierre42100/matrix_gateway -f matrixgw_backend/docker/matrixgw_backend/Dockerfile "$(DOCKER_TEMP_DIR)"
|
||||||
|
rm -rf $(DOCKER_TEMP_DIR)
|
||||||
@@ -19,6 +19,8 @@ docker run --rm -it docker.io/pierre42100/matrix_gateway --help
|
|||||||
|
|
||||||
### Dependencies
|
### Dependencies
|
||||||
```
|
```
|
||||||
|
sudo apt install -y libsqlite3-dev
|
||||||
|
|
||||||
cd matrixgw_backend
|
cd matrixgw_backend
|
||||||
mkdir -p storage/maspostgres storage/synapse
|
mkdir -p storage/maspostgres storage/synapse
|
||||||
docker compose up
|
docker compose up
|
||||||
@@ -53,4 +55,4 @@ cargo fmt && cargo clippy && cargo run --
|
|||||||
cd matrixgw_frontend
|
cd matrixgw_frontend
|
||||||
npm install
|
npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|||||||
1
matrixgw_backend/.gitignore
vendored
1
matrixgw_backend/.gitignore
vendored
@@ -2,3 +2,4 @@ storage
|
|||||||
app_storage
|
app_storage
|
||||||
.idea
|
.idea
|
||||||
target
|
target
|
||||||
|
static
|
||||||
2951
matrixgw_backend/Cargo.lock
generated
2951
matrixgw_backend/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -5,26 +5,35 @@ edition = "2024"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
env_logger = "0.11.8"
|
env_logger = "0.11.8"
|
||||||
log = "0.4.28"
|
log = "0.4.29"
|
||||||
clap = { version = "4.5.51", features = ["derive", "env"] }
|
clap = { version = "4.5.54", features = ["derive", "env"] }
|
||||||
lazy_static = "1.5.0"
|
lazy_static = "1.5.0"
|
||||||
anyhow = "1.0.100"
|
anyhow = "1.0.100"
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
tokio = { version = "1.48.0", features = ["full"] }
|
tokio = { version = "1.48.0", features = ["full"] }
|
||||||
actix-web = "4.11.0"
|
actix-web = "4.12.1"
|
||||||
actix-session = { version = "0.11.0", features = ["redis-session"] }
|
actix-session = { version = "0.11.0", features = ["redis-session"] }
|
||||||
actix-remote-ip = "0.1.0"
|
actix-remote-ip = "0.1.0"
|
||||||
actix-cors = "0.7.1"
|
actix-cors = "0.7.1"
|
||||||
light-openid = "1.0.4"
|
light-openid = "1.0.4"
|
||||||
bytes = "1.10.1"
|
bytes = "1.11.0"
|
||||||
sha2 = "0.10.9"
|
sha2 = "0.10.9"
|
||||||
urlencoding = "2.1.3"
|
|
||||||
base16ct = { version = "0.3.0", features = ["alloc"] }
|
base16ct = { version = "0.3.0", features = ["alloc"] }
|
||||||
futures-util = "0.3.31"
|
futures-util = "0.3.31"
|
||||||
jwt-simple = { version = "0.12.13", default-features = false, features = ["pure-rust"] }
|
jwt-simple = { version = "0.12.13", default-features = false, features = ["pure-rust"] }
|
||||||
thiserror = "2.0.17"
|
thiserror = "2.0.17"
|
||||||
uuid = { version = "1.18.1", features = ["v4", "serde"] }
|
uuid = { version = "1.19.0", features = ["v4", "serde"] }
|
||||||
ipnet = { version = "2.11.0", features = ["serde"] }
|
ipnet = { version = "2.11.0", features = ["serde"] }
|
||||||
rand = "0.9.2"
|
rand = "0.9.2"
|
||||||
hex = "0.4.3"
|
hex = "0.4.3"
|
||||||
mailchecker = "6.0.19"
|
mailchecker = "6.0.19"
|
||||||
|
matrix-sdk = { version = "0.16.0", features = ["e2e-encryption"] }
|
||||||
|
matrix-sdk-ui = "0.16.0"
|
||||||
|
url = "2.5.8"
|
||||||
|
ractor = "0.15.10"
|
||||||
|
serde_json = "1.0.149"
|
||||||
|
lazy-regex = "3.5.1"
|
||||||
|
actix-ws = "0.3.0"
|
||||||
|
infer = "0.19.0"
|
||||||
|
rust-embed = "8.9.0"
|
||||||
|
mime_guess = "2.0.5"
|
||||||
10
matrixgw_backend/docker/matrixgw_backend/Dockerfile
Normal file
10
matrixgw_backend/docker/matrixgw_backend/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y libcurl4 libsqlite3-0 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|
||||||
|
COPY matrixgw_backend /usr/local/bin/matrixgw_backend
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/matrixgw_backend"]
|
||||||
@@ -2,11 +2,13 @@ use clap::Parser;
|
|||||||
use jwt_simple::algorithms::HS256Key;
|
use jwt_simple::algorithms::HS256Key;
|
||||||
use jwt_simple::prelude::{Clock, Duration, JWTClaims, MACLike};
|
use jwt_simple::prelude::{Clock, Duration, JWTClaims, MACLike};
|
||||||
use matrixgw_backend::constants;
|
use matrixgw_backend::constants;
|
||||||
use matrixgw_backend::extractors::auth_extractor::TokenClaims;
|
use matrixgw_backend::extractors::auth_extractor::{MatrixJWTKID, TokenClaims};
|
||||||
|
use matrixgw_backend::users::{APITokenID, UserEmail};
|
||||||
use matrixgw_backend::utils::rand_utils::rand_string;
|
use matrixgw_backend::utils::rand_utils::rand_string;
|
||||||
use std::ops::Add;
|
use std::ops::Add;
|
||||||
use std::os::unix::prelude::CommandExt;
|
use std::os::unix::prelude::CommandExt;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
/// cURL wrapper to query MatrixGW
|
/// cURL wrapper to query MatrixGW
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
@@ -20,9 +22,9 @@ struct Args {
|
|||||||
#[arg(short('i'), long, env)]
|
#[arg(short('i'), long, env)]
|
||||||
token_id: String,
|
token_id: String,
|
||||||
|
|
||||||
/// User ID
|
/// User email
|
||||||
#[arg(short('u'), long, env)]
|
#[arg(short('u'), long, env)]
|
||||||
user_id: String,
|
user_mail: String,
|
||||||
|
|
||||||
/// Token secret
|
/// Token secret
|
||||||
#[arg(short('t'), long, env)]
|
#[arg(short('t'), long, env)]
|
||||||
@@ -69,11 +71,14 @@ fn main() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let jwt = key
|
let jwt = key
|
||||||
.with_key_id(&format!(
|
.with_key_id(
|
||||||
"{}#{}",
|
&MatrixJWTKID {
|
||||||
urlencoding::encode(&args.user_id),
|
user_email: UserEmail(args.user_mail),
|
||||||
urlencoding::encode(&args.token_id)
|
id: APITokenID::from_str(args.token_id.as_str())
|
||||||
))
|
.expect("Failed to decode token ID!"),
|
||||||
|
}
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
.authenticate(claims)
|
.authenticate(claims)
|
||||||
.expect("Failed to sign JWT!");
|
.expect("Failed to sign JWT!");
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
use crate::users::{APITokenID, UserEmail};
|
use crate::users::{APITokenID, UserEmail};
|
||||||
use crate::utils::crypt_utils::sha256str;
|
use crate::utils::crypt_utils::sha256str;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
use matrix_sdk::authentication::oauth::registration::{
|
||||||
|
ApplicationType, ClientMetadata, Localized, OAuthGrantType,
|
||||||
|
};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
/// Matrix gateway backend API
|
/// Matrix gateway backend API
|
||||||
#[derive(Parser, Debug, Clone)]
|
#[derive(Parser, Debug, Clone)]
|
||||||
@@ -76,6 +80,10 @@ pub struct AppConfig {
|
|||||||
#[arg(long, env, default_value = "APP_ORIGIN/oidc_cb")]
|
#[arg(long, env, default_value = "APP_ORIGIN/oidc_cb")]
|
||||||
oidc_redirect_url: String,
|
oidc_redirect_url: String,
|
||||||
|
|
||||||
|
/// Matrix oauth redirect URL
|
||||||
|
#[arg(long, env, default_value = "APP_ORIGIN/matrix_auth_cb")]
|
||||||
|
matrix_oauth_redirect_url: String,
|
||||||
|
|
||||||
/// Application storage path
|
/// Application storage path
|
||||||
#[arg(long, env, default_value = "app_storage")]
|
#[arg(long, env, default_value = "app_storage")]
|
||||||
storage_path: String,
|
storage_path: String,
|
||||||
@@ -146,6 +154,38 @@ impl AppConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Matrix OAuth redirect URL
|
||||||
|
pub fn matrix_oauth_redirect_url(&self) -> String {
|
||||||
|
self.matrix_oauth_redirect_url
|
||||||
|
.replace("APP_ORIGIN", &self.website_origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get Matrix client metadata information
|
||||||
|
pub fn matrix_client_metadata(&self) -> ClientMetadata {
|
||||||
|
let client_uri = Localized::new(
|
||||||
|
Url::parse(&self.website_origin).expect("Invalid website origin!"),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
ClientMetadata {
|
||||||
|
application_type: ApplicationType::Native,
|
||||||
|
grant_types: vec![OAuthGrantType::AuthorizationCode {
|
||||||
|
redirect_uris: vec![
|
||||||
|
Url::parse(&self.matrix_oauth_redirect_url())
|
||||||
|
.expect("Failed to parse matrix auth redirect URI!"),
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
client_name: Some(Localized::new("MatrixGW".to_string(), [])),
|
||||||
|
logo_uri: Some(Localized::new(
|
||||||
|
Url::parse(&format!("{}/favicon.png", self.website_origin))
|
||||||
|
.expect("Invalid website origin!"),
|
||||||
|
[],
|
||||||
|
)),
|
||||||
|
policy_uri: Some(client_uri.clone()),
|
||||||
|
tos_uri: Some(client_uri.clone()),
|
||||||
|
client_uri,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get storage path
|
/// Get storage path
|
||||||
pub fn storage_path(&self) -> &Path {
|
pub fn storage_path(&self) -> &Path {
|
||||||
Path::new(self.storage_path.as_str())
|
Path::new(self.storage_path.as_str())
|
||||||
@@ -170,6 +210,21 @@ impl AppConfig {
|
|||||||
pub fn user_api_token_metadata_file(&self, mail: &UserEmail, id: &APITokenID) -> PathBuf {
|
pub fn user_api_token_metadata_file(&self, mail: &UserEmail, id: &APITokenID) -> PathBuf {
|
||||||
self.user_api_token_directory(mail).join(id.0.to_string())
|
self.user_api_token_directory(mail).join(id.0.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get user Matrix database path
|
||||||
|
pub fn user_matrix_db_path(&self, mail: &UserEmail) -> PathBuf {
|
||||||
|
self.user_directory(mail).join("matrix-db")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get user Matrix database passphrase path
|
||||||
|
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)]
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
|||||||
43
matrixgw_backend/src/broadcast_messages.rs
Normal file
43
matrixgw_backend/src/broadcast_messages.rs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
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::receipt::SyncReceiptEvent;
|
||||||
|
use matrix_sdk::ruma::events::room::message::OriginalSyncRoomMessageEvent;
|
||||||
|
use matrix_sdk::ruma::events::room::redaction::OriginalSyncRoomRedactionEvent;
|
||||||
|
use matrix_sdk::ruma::events::typing::SyncTypingEvent;
|
||||||
|
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>),
|
||||||
|
/// Message fully read event
|
||||||
|
ReceiptEvent(BxRoomEvent<SyncReceiptEvent>),
|
||||||
|
/// User is typing message event
|
||||||
|
TypingEvent(BxRoomEvent<SyncTypingEvent>),
|
||||||
|
/// Raw Matrix sync response
|
||||||
|
MatrixSyncResponse { user: UserEmail, sync: SyncResponse },
|
||||||
|
}
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
/// Auth header
|
/// Auth header
|
||||||
pub const API_AUTH_HEADER: &str = "x-client-auth";
|
pub const API_AUTH_HEADER: &str = "x-client-auth";
|
||||||
|
|
||||||
/// Max token validity, in seconds
|
/// Max token validity, in seconds
|
||||||
pub const API_TOKEN_JWT_MAX_DURATION: u64 = 15 * 60;
|
pub const API_TOKEN_JWT_MAX_DURATION: u64 = 15 * 60;
|
||||||
|
|
||||||
|
/// Length of generated tokens
|
||||||
|
pub const TOKENS_LEN: usize = 50;
|
||||||
|
|
||||||
/// Session-specific constants
|
/// Session-specific constants
|
||||||
pub mod sessions {
|
pub mod sessions {
|
||||||
/// OpenID auth session state key
|
/// OpenID auth session state key
|
||||||
@@ -13,3 +18,11 @@ pub mod sessions {
|
|||||||
/// Authenticated ID
|
/// Authenticated ID
|
||||||
pub const USER_ID: &str = "uid";
|
pub const USER_ID: &str = "uid";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How often heartbeat pings are sent.
|
||||||
|
///
|
||||||
|
/// Should be half (or less) of the acceptable client timeout.
|
||||||
|
pub const WS_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
/// How long before lack of client response causes a timeout.
|
||||||
|
pub const WS_CLIENT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
use crate::app_config::AppConfig;
|
use crate::app_config::AppConfig;
|
||||||
|
use crate::broadcast_messages::BroadcastSender;
|
||||||
use crate::controllers::{HttpFailure, HttpResult};
|
use crate::controllers::{HttpFailure, HttpResult};
|
||||||
use crate::extractors::auth_extractor::{AuthExtractor, AuthenticatedMethod};
|
use crate::extractors::auth_extractor::{AuthExtractor, AuthenticatedMethod};
|
||||||
|
use crate::extractors::matrix_client_extractor::MatrixClientExtractor;
|
||||||
use crate::extractors::session_extractor::MatrixGWSession;
|
use crate::extractors::session_extractor::MatrixGWSession;
|
||||||
use crate::users::{User, UserEmail};
|
use crate::users::{User, UserEmail};
|
||||||
use actix_remote_ip::RemoteIP;
|
use actix_remote_ip::RemoteIP;
|
||||||
@@ -107,19 +109,23 @@ pub async fn finish_oidc(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get current user information
|
/// Get current user information
|
||||||
pub async fn auth_info(auth: AuthExtractor) -> HttpResult {
|
pub async fn auth_info(client: MatrixClientExtractor) -> HttpResult {
|
||||||
Ok(HttpResponse::Ok().json(auth.user))
|
Ok(HttpResponse::Ok().json(client.to_extended_user_info().await?))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sign out user
|
/// Sign out user
|
||||||
pub async fn sign_out(auth: AuthExtractor, session: MatrixGWSession) -> HttpResult {
|
pub async fn sign_out(
|
||||||
|
auth: AuthExtractor,
|
||||||
|
session: MatrixGWSession,
|
||||||
|
tx: web::Data<BroadcastSender>,
|
||||||
|
) -> HttpResult {
|
||||||
match auth.method {
|
match auth.method {
|
||||||
AuthenticatedMethod::Cookie => {
|
AuthenticatedMethod::Cookie => {
|
||||||
session.unset_current_user()?;
|
session.unset_current_user()?;
|
||||||
}
|
}
|
||||||
|
|
||||||
AuthenticatedMethod::Token(token) => {
|
AuthenticatedMethod::Token(token) => {
|
||||||
token.delete(&auth.user.email).await?;
|
token.delete(&auth.user.email, &tx).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
AuthenticatedMethod::Dev => {
|
AuthenticatedMethod::Dev => {
|
||||||
|
|||||||
@@ -0,0 +1,302 @@
|
|||||||
|
use crate::controllers::HttpResult;
|
||||||
|
use crate::controllers::matrix::matrix_media_controller;
|
||||||
|
use crate::controllers::matrix::matrix_media_controller::MediaQuery;
|
||||||
|
use crate::controllers::matrix::matrix_room_controller::RoomIdInPath;
|
||||||
|
use crate::extractors::matrix_client_extractor::MatrixClientExtractor;
|
||||||
|
use actix_web::dev::Payload;
|
||||||
|
use actix_web::{FromRequest, HttpRequest, HttpResponse, web};
|
||||||
|
use futures_util::{StreamExt, stream};
|
||||||
|
use matrix_sdk::Room;
|
||||||
|
use matrix_sdk::deserialized_responses::{TimelineEvent, TimelineEventKind};
|
||||||
|
use matrix_sdk::media::MediaEventContent;
|
||||||
|
use matrix_sdk::room::MessagesOptions;
|
||||||
|
use matrix_sdk::room::edit::EditedContent;
|
||||||
|
use matrix_sdk::ruma::api::client::filter::RoomEventFilter;
|
||||||
|
use matrix_sdk::ruma::api::client::receipt::create_receipt::v3::ReceiptType;
|
||||||
|
use matrix_sdk::ruma::events::reaction::ReactionEventContent;
|
||||||
|
use matrix_sdk::ruma::events::receipt::ReceiptThread;
|
||||||
|
use matrix_sdk::ruma::events::relation::Annotation;
|
||||||
|
use matrix_sdk::ruma::events::room::message::{
|
||||||
|
MessageType, RoomMessageEvent, RoomMessageEventContent, RoomMessageEventContentWithoutRelation,
|
||||||
|
};
|
||||||
|
use matrix_sdk::ruma::events::{AnyMessageLikeEvent, AnyTimelineEvent};
|
||||||
|
use matrix_sdk::ruma::{MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedUserId, RoomId, UInt};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::value::RawValue;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct APIEvent {
|
||||||
|
pub 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>,
|
||||||
|
filter: Option<RoomEventFilter>,
|
||||||
|
) -> anyhow::Result<APIEventsList> {
|
||||||
|
let mut msg_opts = MessagesOptions::backward();
|
||||||
|
msg_opts.from = from.map(str::to_string);
|
||||||
|
msg_opts.limit = UInt::from(limit);
|
||||||
|
if let Some(filter) = filter {
|
||||||
|
msg_opts.filter = filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.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}"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn event_file(
|
||||||
|
req: HttpRequest,
|
||||||
|
client: MatrixClientExtractor,
|
||||||
|
path: web::Path<RoomIdInPath>,
|
||||||
|
event_path: web::Path<EventIdInPath>,
|
||||||
|
) -> HttpResult {
|
||||||
|
let query = web::Query::<MediaQuery>::from_request(&req, &mut Payload::None).await?;
|
||||||
|
|
||||||
|
let Some(room) = client.client.client.get_room(&path.room_id) else {
|
||||||
|
return Ok(HttpResponse::NotFound().json("Room not found!"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let event = match room.load_or_fetch_event(&event_path.event_id, None).await {
|
||||||
|
Ok(event) => event,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to load event information! {e}");
|
||||||
|
return Ok(HttpResponse::InternalServerError()
|
||||||
|
.json(format!("Failed to load event information! {e}")));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let event = match event.kind {
|
||||||
|
TimelineEventKind::Decrypted(dec) => dec.event.deserialize()?,
|
||||||
|
TimelineEventKind::UnableToDecrypt { event, .. }
|
||||||
|
| TimelineEventKind::PlainText { event } => event
|
||||||
|
.deserialize()?
|
||||||
|
.into_full_event(room.room_id().to_owned()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let AnyTimelineEvent::MessageLike(message) = event else {
|
||||||
|
return Ok(HttpResponse::BadRequest().json("Event is not message like!"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let AnyMessageLikeEvent::RoomMessage(message) = message else {
|
||||||
|
return Ok(HttpResponse::BadRequest().json("Event is not a room message!"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let RoomMessageEvent::Original(message) = message else {
|
||||||
|
return Ok(HttpResponse::BadRequest().json("Event has been redacted!"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let (source, thumb_source) = match message.content.msgtype {
|
||||||
|
MessageType::Audio(c) => (c.source(), c.thumbnail_source()),
|
||||||
|
MessageType::File(c) => (c.source(), c.thumbnail_source()),
|
||||||
|
MessageType::Image(c) => (c.source(), c.thumbnail_source()),
|
||||||
|
MessageType::Location(c) => (c.source(), c.thumbnail_source()),
|
||||||
|
MessageType::Video(c) => (c.source(), c.thumbnail_source()),
|
||||||
|
_ => (None, None),
|
||||||
|
};
|
||||||
|
|
||||||
|
let source = match (query.thumbnail, source, thumb_source) {
|
||||||
|
(false, Some(s), _) => s,
|
||||||
|
(true, _, Some(s)) => s,
|
||||||
|
_ => return Ok(HttpResponse::NotFound().json("Requested file not available!")),
|
||||||
|
};
|
||||||
|
|
||||||
|
matrix_media_controller::serve_media(req, source, false).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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}"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send receipt for event
|
||||||
|
pub async fn receipt(
|
||||||
|
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"));
|
||||||
|
};
|
||||||
|
|
||||||
|
room.send_single_receipt(
|
||||||
|
ReceiptType::Read,
|
||||||
|
ReceiptThread::Main,
|
||||||
|
event_path.event_id.clone(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(HttpResponse::Accepted().finish())
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
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)]
|
||||||
|
pub struct MediaMXCInPath {
|
||||||
|
mxc: OwnedMxcUri,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serve media resource handler
|
||||||
|
pub async fn serve_mxc_handler(req: HttpRequest, media: web::Path<MediaMXCInPath>) -> HttpResult {
|
||||||
|
serve_mxc_file(req, media.into_inner().mxc).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct MediaQuery {
|
||||||
|
#[serde(default)]
|
||||||
|
pub thumbnail: bool,
|
||||||
|
}
|
||||||
|
pub async fn serve_mxc_file(req: HttpRequest, media: OwnedMxcUri) -> HttpResult {
|
||||||
|
let query = web::Query::<MediaQuery>::from_request(&req, &mut Payload::None).await?;
|
||||||
|
|
||||||
|
serve_media(req, MediaSource::Plain(media), query.thumbnail).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serve a media file
|
||||||
|
pub async fn serve_media(req: HttpRequest, source: MediaSource, thumbnail: bool) -> HttpResult {
|
||||||
|
let client = MatrixClientExtractor::from_request(&req, &mut Payload::None).await?;
|
||||||
|
|
||||||
|
let media = client
|
||||||
|
.client
|
||||||
|
.client
|
||||||
|
.media()
|
||||||
|
.get_media_content(
|
||||||
|
&MediaRequestParameters {
|
||||||
|
source: source.clone(),
|
||||||
|
format: match 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))
|
||||||
|
}
|
||||||
@@ -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,180 @@
|
|||||||
|
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::notification_settings::{
|
||||||
|
IsEncrypted, IsOneToOne, NotificationSettings, RoomNotificationMode,
|
||||||
|
};
|
||||||
|
use matrix_sdk::room::ParentSpace;
|
||||||
|
use matrix_sdk::ruma::events::receipt::{ReceiptThread, ReceiptType};
|
||||||
|
use matrix_sdk::ruma::{
|
||||||
|
MilliSecondsSinceUnixEpoch, OwnedEventId, 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,
|
||||||
|
notifications: RoomNotificationMode,
|
||||||
|
latest_event: Option<APIEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl APIRoomInfo {
|
||||||
|
async fn from_room(r: &Room, notif: &NotificationSettings) -> 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<_>>();
|
||||||
|
|
||||||
|
let members = r
|
||||||
|
.members(RoomMemberships::ACTIVE)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| r.user_id().to_owned())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let notifications = notif
|
||||||
|
.get_user_defined_room_notification_mode(r.room_id())
|
||||||
|
.await
|
||||||
|
.unwrap_or(
|
||||||
|
notif
|
||||||
|
.get_default_room_notification_mode(
|
||||||
|
IsEncrypted::from(r.encryption_state().is_encrypted()),
|
||||||
|
IsOneToOne::from(members.len() == 2),
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
id: r.room_id().to_owned(),
|
||||||
|
name: r.name(),
|
||||||
|
members,
|
||||||
|
avatar: r.avatar_url(),
|
||||||
|
is_space: r.is_space(),
|
||||||
|
parents: parent_spaces,
|
||||||
|
number_unread_messages: r.unread_notification_counts().notification_count,
|
||||||
|
notifications,
|
||||||
|
latest_event: get_events(r, 1, None, None)
|
||||||
|
.await?
|
||||||
|
.events
|
||||||
|
.into_iter()
|
||||||
|
.next(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the list of joined rooms of the user
|
||||||
|
pub async fn joined_rooms(client: MatrixClientExtractor) -> HttpResult {
|
||||||
|
let notifs = client.client.client.notification_settings().await;
|
||||||
|
let list = stream::iter(client.client.client.joined_rooms())
|
||||||
|
.then(async |room| APIRoomInfo::from_room(&room, ¬ifs).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 notifs = client.client.client.notification_settings().await;
|
||||||
|
|
||||||
|
let list = stream::iter(client.client.client.joined_space_rooms())
|
||||||
|
.then(async |room| APIRoomInfo::from_room(&room, ¬ifs).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 {
|
||||||
|
let notifs = client.client.client.notification_settings().await;
|
||||||
|
|
||||||
|
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, ¬ifs).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_mxc_file(req, uri).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
pub struct UserReceipt {
|
||||||
|
user: OwnedUserId,
|
||||||
|
event_id: OwnedEventId,
|
||||||
|
ts: Option<MilliSecondsSinceUnixEpoch>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get room receipts
|
||||||
|
pub async fn receipts(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 members = room.members(RoomMemberships::ACTIVE).await?;
|
||||||
|
|
||||||
|
let mut receipts = Vec::new();
|
||||||
|
for m in members {
|
||||||
|
let Some((event_id, receipt)) = room
|
||||||
|
.load_user_receipt(ReceiptType::Read, ReceiptThread::Main, m.user_id())
|
||||||
|
.await?
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
receipts.push(UserReceipt {
|
||||||
|
user: m.user_id().to_owned(),
|
||||||
|
event_id,
|
||||||
|
ts: receipt.ts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok().json(receipts))
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
use crate::controllers::HttpResult;
|
||||||
|
use crate::extractors::matrix_client_extractor::MatrixClientExtractor;
|
||||||
|
use actix_web::HttpResponse;
|
||||||
|
use matrix_sdk_ui::spaces::SpaceService;
|
||||||
|
use matrix_sdk_ui::spaces::room_list::SpaceRoomListPaginationState;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// Get space hierarchy
|
||||||
|
pub async fn hierarchy(client: MatrixClientExtractor) -> HttpResult {
|
||||||
|
let spaces = client.client.client.joined_space_rooms();
|
||||||
|
let space_service = SpaceService::new(client.client.client);
|
||||||
|
let mut hierarchy = HashMap::new();
|
||||||
|
for space in spaces {
|
||||||
|
let rooms = space_service
|
||||||
|
.space_room_list(space.room_id().to_owned())
|
||||||
|
.await;
|
||||||
|
while !matches!(
|
||||||
|
rooms.pagination_state(),
|
||||||
|
SpaceRoomListPaginationState::Idle { end_reached: true }
|
||||||
|
) {
|
||||||
|
rooms.paginate().await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
hierarchy.insert(
|
||||||
|
space.room_id().to_owned(),
|
||||||
|
rooms
|
||||||
|
.rooms()
|
||||||
|
.into_iter()
|
||||||
|
.map(|room| room.room_id)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(HttpResponse::Ok().json(hierarchy))
|
||||||
|
}
|
||||||
5
matrixgw_backend/src/controllers/matrix/mod.rs
Normal file
5
matrixgw_backend/src/controllers/matrix/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
pub mod matrix_event_controller;
|
||||||
|
pub mod matrix_media_controller;
|
||||||
|
pub mod matrix_profile_controller;
|
||||||
|
pub mod matrix_room_controller;
|
||||||
|
pub mod matrix_space_controller;
|
||||||
59
matrixgw_backend/src/controllers/matrix_link_controller.rs
Normal file
59
matrixgw_backend/src/controllers/matrix_link_controller.rs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
use crate::controllers::HttpResult;
|
||||||
|
use crate::extractors::auth_extractor::AuthExtractor;
|
||||||
|
use crate::extractors::matrix_client_extractor::MatrixClientExtractor;
|
||||||
|
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 {
|
||||||
|
url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start user authentication on Matrix server
|
||||||
|
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,7 +3,13 @@ use actix_web::{HttpResponse, ResponseError};
|
|||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
|
|
||||||
pub mod auth_controller;
|
pub mod auth_controller;
|
||||||
|
pub mod matrix;
|
||||||
|
pub mod matrix_link_controller;
|
||||||
|
pub mod matrix_sync_thread_controller;
|
||||||
pub mod server_controller;
|
pub mod server_controller;
|
||||||
|
pub mod static_controller;
|
||||||
|
pub mod tokens_controller;
|
||||||
|
pub mod ws_controller;
|
||||||
|
|
||||||
#[derive(thiserror::Error, Debug)]
|
#[derive(thiserror::Error, Debug)]
|
||||||
pub enum HttpFailure {
|
pub enum HttpFailure {
|
||||||
@@ -15,6 +21,16 @@ pub enum HttpFailure {
|
|||||||
OpenID(Box<dyn Error>),
|
OpenID(Box<dyn Error>),
|
||||||
#[error("an unspecified internal error occurred: {0}")]
|
#[error("an unspecified internal error occurred: {0}")]
|
||||||
InternalError(#[from] anyhow::Error),
|
InternalError(#[from] anyhow::Error),
|
||||||
|
#[error("Actix web error: {0}")]
|
||||||
|
ActixError(#[from] actix_web::Error),
|
||||||
|
#[error("Matrix error: {0}")]
|
||||||
|
MatrixError(#[from] matrix_sdk::Error),
|
||||||
|
#[error("Matrix decryptor error: {0}")]
|
||||||
|
MatrixDecryptorError(#[from] matrix_sdk::encryption::DecryptorError),
|
||||||
|
#[error("Serde JSON error: {0}")]
|
||||||
|
SerdeJSON(#[from] serde_json::Error),
|
||||||
|
#[error("Standard library error: {0}")]
|
||||||
|
StdLibError(#[from] std::io::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ResponseError for HttpFailure {
|
impl ResponseError for HttpFailure {
|
||||||
@@ -27,7 +43,9 @@ impl ResponseError for HttpFailure {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn error_response(&self) -> HttpResponse {
|
fn error_response(&self) -> HttpResponse {
|
||||||
HttpResponse::build(self.status_code()).body(self.to_string())
|
HttpResponse::build(self.status_code())
|
||||||
|
.content_type("text/plain")
|
||||||
|
.body(self.to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
45
matrixgw_backend/src/controllers/static_controller.rs
Normal file
45
matrixgw_backend/src/controllers/static_controller.rs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
#[cfg(debug_assertions)]
|
||||||
|
pub use serve_static_debug::{root_index, serve_static_content};
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
pub use serve_static_release::{root_index, serve_static_content};
|
||||||
|
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
mod serve_static_debug {
|
||||||
|
use actix_web::{HttpResponse, Responder};
|
||||||
|
|
||||||
|
pub async fn root_index() -> impl Responder {
|
||||||
|
HttpResponse::Ok().body("Hello world! Debug=on for Matrix Gateway!")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn serve_static_content() -> impl Responder {
|
||||||
|
HttpResponse::NotFound().body("Hello world! Static assets are not served in debug mode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
mod serve_static_release {
|
||||||
|
use actix_web::{HttpResponse, Responder, web};
|
||||||
|
use rust_embed::RustEmbed;
|
||||||
|
|
||||||
|
#[derive(RustEmbed)]
|
||||||
|
#[folder = "static/"]
|
||||||
|
struct Asset;
|
||||||
|
|
||||||
|
fn handle_embedded_file(path: &str, can_fallback: bool) -> HttpResponse {
|
||||||
|
match (Asset::get(path), can_fallback) {
|
||||||
|
(Some(content), _) => HttpResponse::Ok()
|
||||||
|
.content_type(mime_guess::from_path(path).first_or_octet_stream().as_ref())
|
||||||
|
.body(content.data.into_owned()),
|
||||||
|
(None, false) => HttpResponse::NotFound().body("404 Not Found"),
|
||||||
|
(None, true) => handle_embedded_file("index.html", false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn root_index() -> impl Responder {
|
||||||
|
handle_embedded_file("index.html", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn serve_static_content(path: web::Path<String>) -> impl Responder {
|
||||||
|
handle_embedded_file(path.as_ref(), !path.as_ref().starts_with("static/"))
|
||||||
|
}
|
||||||
|
}
|
||||||
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())
|
||||||
|
}
|
||||||
307
matrixgw_backend/src/controllers/ws_controller.rs
Normal file
307
matrixgw_backend/src/controllers/ws_controller.rs
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Serialize)]
|
||||||
|
pub struct WsReceiptEntry {
|
||||||
|
event: OwnedEventId,
|
||||||
|
user: OwnedUserId,
|
||||||
|
ts: Option<MilliSecondsSinceUnixEpoch>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Serialize)]
|
||||||
|
pub struct WsReceiptEvent {
|
||||||
|
pub room_id: OwnedRoomId,
|
||||||
|
pub receipts: Vec<WsReceiptEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Serialize)]
|
||||||
|
pub struct WsTypingEvent {
|
||||||
|
pub room_id: OwnedRoomId,
|
||||||
|
pub user_ids: Vec<OwnedUserId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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>),
|
||||||
|
|
||||||
|
/// Fully read message event
|
||||||
|
ReceiptEvent(WsReceiptEvent),
|
||||||
|
|
||||||
|
/// User is typing event
|
||||||
|
TypingEvent(WsTypingEvent),
|
||||||
|
}
|
||||||
|
|
||||||
|
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()),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
BroadcastMessage::ReceiptEvent(evt) if &evt.user == user => {
|
||||||
|
let mut receipts = vec![];
|
||||||
|
for (event_id, r) in &evt.data.content.0 {
|
||||||
|
for user_receipts in r.values() {
|
||||||
|
for (user, receipt) in user_receipts {
|
||||||
|
receipts.push(WsReceiptEntry {
|
||||||
|
event: event_id.clone(),
|
||||||
|
user: user.clone(),
|
||||||
|
ts: receipt.ts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(Self::ReceiptEvent(WsReceiptEvent {
|
||||||
|
room_id: evt.room.room_id().to_owned(),
|
||||||
|
receipts,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
BroadcastMessage::TypingEvent(evt) if &evt.user == user => {
|
||||||
|
Some(Self::TypingEvent(WsTypingEvent {
|
||||||
|
room_id: evt.room.room_id().to_owned(),
|
||||||
|
user_ids: evt.data.content.user_ids.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 bytes::Bytes;
|
||||||
use jwt_simple::common::VerificationOptions;
|
use jwt_simple::common::VerificationOptions;
|
||||||
use jwt_simple::prelude::{Duration, HS256Key, MACLike};
|
use jwt_simple::prelude::{Duration, HS256Key, MACLike};
|
||||||
|
use jwt_simple::reexports::serde_json;
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use std::fmt::Display;
|
use std::fmt::Display;
|
||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
@@ -26,12 +28,38 @@ pub enum AuthenticatedMethod {
|
|||||||
Token(APIToken),
|
Token(APIToken),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl AuthenticatedMethod {
|
||||||
|
pub fn light_str(&self) -> String {
|
||||||
|
match self {
|
||||||
|
AuthenticatedMethod::Cookie => "Cookie".to_string(),
|
||||||
|
AuthenticatedMethod::Dev => "DevAuthentication".to_string(),
|
||||||
|
AuthenticatedMethod::Token(t) => format!("Token({:?} - {})", t.id, t.base.name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct AuthExtractor {
|
pub struct AuthExtractor {
|
||||||
pub user: User,
|
pub user: User,
|
||||||
pub method: AuthenticatedMethod,
|
pub method: AuthenticatedMethod,
|
||||||
pub payload: Option<Vec<u8>>,
|
pub payload: Option<Vec<u8>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl AsRef<User> for AuthExtractor {
|
||||||
|
fn as_ref(&self) -> &User {
|
||||||
|
&self.user
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthExtractor {
|
||||||
|
pub fn decode_json_body<E: DeserializeOwned + Send>(&self) -> anyhow::Result<E> {
|
||||||
|
let payload = self
|
||||||
|
.payload
|
||||||
|
.as_ref()
|
||||||
|
.context("Failed to decode request as json: missing payload!")?;
|
||||||
|
Ok(serde_json::from_slice(payload)?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq)]
|
#[derive(Debug, Eq, PartialEq)]
|
||||||
pub struct MatrixJWTKID {
|
pub struct MatrixJWTKID {
|
||||||
pub user_email: UserEmail,
|
pub user_email: UserEmail,
|
||||||
@@ -144,8 +172,9 @@ impl AuthExtractor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check IP restriction
|
// Check IP restriction
|
||||||
if let Some(net) = token.network
|
if let Some(nets) = &token.base.networks
|
||||||
&& !net.contains(&remote_ip)
|
&& !nets.is_empty()
|
||||||
|
&& !nets.iter().any(|n| n.contains(&remote_ip))
|
||||||
{
|
{
|
||||||
log::error!(
|
log::error!(
|
||||||
"Trying to use token {:?} from unauthorized IP address: {remote_ip:?}",
|
"Trying to use token {:?} from unauthorized IP address: {remote_ip:?}",
|
||||||
@@ -157,7 +186,7 @@ impl AuthExtractor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for write access
|
// Check for write access
|
||||||
if token.read_only && !req.method().is_safe() {
|
if token.base.read_only && !req.method().is_safe() {
|
||||||
return Err(actix_web::error::ErrorBadRequest(
|
return Err(actix_web::error::ErrorBadRequest(
|
||||||
"Read only token cannot perform write operations!",
|
"Read only token cannot perform write operations!",
|
||||||
));
|
));
|
||||||
|
|||||||
54
matrixgw_backend/src/extractors/matrix_client_extractor.rs
Normal file
54
matrixgw_backend/src/extractors/matrix_client_extractor.rs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
pub struct MatrixClientExtractor {
|
||||||
|
pub auth: AuthExtractor,
|
||||||
|
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>>;
|
||||||
|
|
||||||
|
fn from_request(req: &HttpRequest, payload: &mut Payload) -> Self::Future {
|
||||||
|
let req = req.clone();
|
||||||
|
let mut payload = payload.take();
|
||||||
|
Box::pin(async move {
|
||||||
|
let auth = AuthExtractor::from_request(&req, &mut payload).await?;
|
||||||
|
|
||||||
|
let matrix_manager_actor =
|
||||||
|
web::Data::<ActorRef<MatrixManagerMsg>>::from_request(&req, &mut Payload::None)
|
||||||
|
.await?;
|
||||||
|
let client = ractor::call!(
|
||||||
|
matrix_manager_actor,
|
||||||
|
MatrixManagerMsg::GetClient,
|
||||||
|
auth.user.email.clone()
|
||||||
|
);
|
||||||
|
|
||||||
|
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,2 +1,3 @@
|
|||||||
pub mod auth_extractor;
|
pub mod auth_extractor;
|
||||||
|
pub mod matrix_client_extractor;
|
||||||
pub mod session_extractor;
|
pub mod session_extractor;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
pub mod app_config;
|
pub mod app_config;
|
||||||
|
pub mod broadcast_messages;
|
||||||
pub mod constants;
|
pub mod constants;
|
||||||
pub mod controllers;
|
pub mod controllers;
|
||||||
pub mod extractors;
|
pub mod extractors;
|
||||||
|
pub mod matrix_connection;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
|
|||||||
@@ -7,9 +7,19 @@ use actix_web::cookie::Key;
|
|||||||
use actix_web::middleware::Logger;
|
use actix_web::middleware::Logger;
|
||||||
use actix_web::{App, HttpServer, web};
|
use actix_web::{App, HttpServer, web};
|
||||||
use matrixgw_backend::app_config::AppConfig;
|
use matrixgw_backend::app_config::AppConfig;
|
||||||
|
use matrixgw_backend::broadcast_messages::BroadcastMessage;
|
||||||
use matrixgw_backend::constants;
|
use matrixgw_backend::constants;
|
||||||
use matrixgw_backend::controllers::{auth_controller, server_controller};
|
use matrixgw_backend::controllers::matrix::{
|
||||||
|
matrix_event_controller, matrix_media_controller, matrix_profile_controller,
|
||||||
|
matrix_room_controller, matrix_space_controller,
|
||||||
|
};
|
||||||
|
use matrixgw_backend::controllers::{
|
||||||
|
auth_controller, matrix_link_controller, matrix_sync_thread_controller, server_controller,
|
||||||
|
static_controller, tokens_controller, ws_controller,
|
||||||
|
};
|
||||||
|
use matrixgw_backend::matrix_connection::matrix_manager::MatrixManagerActor;
|
||||||
use matrixgw_backend::users::User;
|
use matrixgw_backend::users::User;
|
||||||
|
use ractor::Actor;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> std::io::Result<()> {
|
async fn main() -> std::io::Result<()> {
|
||||||
@@ -17,10 +27,13 @@ async fn main() -> std::io::Result<()> {
|
|||||||
|
|
||||||
let secret_key = Key::from(AppConfig::get().secret().as_bytes());
|
let secret_key = Key::from(AppConfig::get().secret().as_bytes());
|
||||||
|
|
||||||
|
log::info!("Connect to Redis session store...");
|
||||||
let redis_store = RedisSessionStore::new(AppConfig::get().redis_connection_string())
|
let redis_store = RedisSessionStore::new(AppConfig::get().redis_connection_string())
|
||||||
.await
|
.await
|
||||||
.expect("Failed to connect to Redis!");
|
.expect("Failed to connect to Redis!");
|
||||||
|
|
||||||
|
let (ws_tx, _) = tokio::sync::broadcast::channel::<BroadcastMessage>(16);
|
||||||
|
|
||||||
// Auto create default account, if requested
|
// Auto create default account, if requested
|
||||||
if let Some(mail) = &AppConfig::get().unsecure_auto_login_email() {
|
if let Some(mail) = &AppConfig::get().unsecure_auto_login_email() {
|
||||||
User::create_or_update_user(mail, "Anonymous")
|
User::create_or_update_user(mail, "Anonymous")
|
||||||
@@ -28,12 +41,22 @@ async fn main() -> std::io::Result<()> {
|
|||||||
.expect("Failed to create auto-login account!");
|
.expect("Failed to create auto-login account!");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create matrix clients manager actor
|
||||||
|
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!");
|
||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
"Starting to listen on {} for {}",
|
"Starting to listen on {} for {}",
|
||||||
AppConfig::get().listen_address,
|
AppConfig::get().listen_address,
|
||||||
AppConfig::get().website_origin
|
AppConfig::get().website_origin
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let manager_actor_clone = manager_actor.clone();
|
||||||
HttpServer::new(move || {
|
HttpServer::new(move || {
|
||||||
let session_mw = SessionMiddleware::builder(redis_store.clone(), secret_key.clone())
|
let session_mw = SessionMiddleware::builder(redis_store.clone(), secret_key.clone())
|
||||||
.cookie_name("matrixgw-session".to_string())
|
.cookie_name("matrixgw-session".to_string())
|
||||||
@@ -42,7 +65,7 @@ async fn main() -> std::io::Result<()> {
|
|||||||
|
|
||||||
let cors = Cors::default()
|
let cors = Cors::default()
|
||||||
.allowed_origin(&AppConfig::get().website_origin)
|
.allowed_origin(&AppConfig::get().website_origin)
|
||||||
.allowed_methods(vec!["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"])
|
.allowed_methods(["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"])
|
||||||
.allowed_header(constants::API_AUTH_HEADER)
|
.allowed_header(constants::API_AUTH_HEADER)
|
||||||
.allow_any_header()
|
.allow_any_header()
|
||||||
.supports_credentials()
|
.supports_credentials()
|
||||||
@@ -52,9 +75,11 @@ async fn main() -> std::io::Result<()> {
|
|||||||
.wrap(Logger::default())
|
.wrap(Logger::default())
|
||||||
.wrap(session_mw)
|
.wrap(session_mw)
|
||||||
.wrap(cors)
|
.wrap(cors)
|
||||||
|
.app_data(web::Data::new(manager_actor_clone.clone()))
|
||||||
.app_data(web::Data::new(RemoteIPConfig {
|
.app_data(web::Data::new(RemoteIPConfig {
|
||||||
proxy: AppConfig::get().proxy_ip.clone(),
|
proxy: AppConfig::get().proxy_ip.clone(),
|
||||||
}))
|
}))
|
||||||
|
.app_data(web::Data::new(ws_tx.clone()))
|
||||||
// Server controller
|
// Server controller
|
||||||
.route("/robots.txt", web::get().to(server_controller::robots_txt))
|
.route("/robots.txt", web::get().to(server_controller::robots_txt))
|
||||||
.route(
|
.route(
|
||||||
@@ -75,9 +100,128 @@ async fn main() -> std::io::Result<()> {
|
|||||||
"/api/auth/sign_out",
|
"/api/auth/sign_out",
|
||||||
web::get().to(auth_controller::sign_out),
|
web::get().to(auth_controller::sign_out),
|
||||||
)
|
)
|
||||||
|
// Matrix link controller
|
||||||
|
.route(
|
||||||
|
"/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 spaces controller
|
||||||
|
.route(
|
||||||
|
"/api/matrix/space/hierarchy",
|
||||||
|
web::get().to(matrix_space_controller::hierarchy),
|
||||||
|
)
|
||||||
|
// 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),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/matrix/room/{room_id}/receipts",
|
||||||
|
web::get().to(matrix_room_controller::receipts),
|
||||||
|
)
|
||||||
|
// 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}/file",
|
||||||
|
web::get().to(matrix_event_controller::event_file),
|
||||||
|
)
|
||||||
|
.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),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/matrix/room/{room_id}/event/{event_id}/receipt",
|
||||||
|
web::post().to(matrix_event_controller::receipt),
|
||||||
|
)
|
||||||
|
// Matrix media controller
|
||||||
|
.route(
|
||||||
|
"/api/matrix/media/{mxc}",
|
||||||
|
web::get().to(matrix_media_controller::serve_mxc_handler),
|
||||||
|
)
|
||||||
|
// Static assets
|
||||||
|
.route("/", web::get().to(static_controller::root_index))
|
||||||
|
.route(
|
||||||
|
"/{tail:.*}",
|
||||||
|
web::get().to(static_controller::serve_static_content),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
.workers(4)
|
.workers(4)
|
||||||
.bind(&AppConfig::get().listen_address)?
|
.bind(&AppConfig::get().listen_address)?
|
||||||
.run()
|
.run()
|
||||||
.await
|
.await?;
|
||||||
|
|
||||||
|
// Terminate manager actor
|
||||||
|
manager_actor.stop(None);
|
||||||
|
manager_actor_handle.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
401
matrixgw_backend/src/matrix_connection/matrix_client.rs
Normal file
401
matrixgw_backend/src/matrix_connection/matrix_client.rs
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
use crate::app_config::AppConfig;
|
||||||
|
use crate::matrix_connection::matrix_manager::MatrixManagerMsg;
|
||||||
|
use crate::users::UserEmail;
|
||||||
|
use crate::utils::rand_utils::rand_string;
|
||||||
|
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::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}")]
|
||||||
|
CreateDbPassphrase(std::io::Error),
|
||||||
|
#[error("Failed to read database passphrase! {0}")]
|
||||||
|
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(
|
||||||
|
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)?;
|
||||||
|
|
||||||
|
// Generate or load passphrase
|
||||||
|
let passphrase_path = AppConfig::get().user_matrix_passphrase_path(email);
|
||||||
|
if !passphrase_path.exists() {
|
||||||
|
std::fs::write(&passphrase_path, rand_string(32))
|
||||||
|
.map_err(MatrixClientError::CreateDbPassphrase)?;
|
||||||
|
}
|
||||||
|
let passphrase = std::fs::read_to_string(passphrase_path)
|
||||||
|
.map_err(MatrixClientError::ReadDbPassphrase)?;
|
||||||
|
|
||||||
|
let client = Client::builder()
|
||||||
|
.homeserver_url(&AppConfig::get().matrix_homeserver)
|
||||||
|
// Automatically refresh tokens if needed
|
||||||
|
.handle_refresh_tokens()
|
||||||
|
.sqlite_store(&db_path, Some(&passphrase))
|
||||||
|
.build()
|
||||||
|
.await
|
||||||
|
.map_err(MatrixClientError::BuildMatrixClient)?;
|
||||||
|
|
||||||
|
let client = Self {
|
||||||
|
manager,
|
||||||
|
email: email.clone(),
|
||||||
|
client,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check metadata
|
||||||
|
if !is_restoring {
|
||||||
|
let oauth = client.client.oauth();
|
||||||
|
let server_metadata = oauth
|
||||||
|
.server_metadata()
|
||||||
|
.await
|
||||||
|
.map_err(MatrixClientError::FetchServerMetadata)?;
|
||||||
|
log::info!("OAuth2 server issuer: {:?}", server_metadata.issuer);
|
||||||
|
} else {
|
||||||
|
let session: StoredSession = serde_json::from_str(
|
||||||
|
std::fs::read_to_string(session_file_path)
|
||||||
|
.map_err(MatrixClientError::LoadStoredSession)?
|
||||||
|
.as_str(),
|
||||||
|
)
|
||||||
|
.map_err(MatrixClientError::DecodeStoredSession)?;
|
||||||
|
|
||||||
|
// Restore session
|
||||||
|
client
|
||||||
|
.client
|
||||||
|
.restore_session(OAuthSession {
|
||||||
|
client_id: session.client_id,
|
||||||
|
user: session.user_session,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(MatrixClientError::RestoreSession)?;
|
||||||
|
|
||||||
|
// Wait for encryption tasks to complete
|
||||||
|
client
|
||||||
|
.client
|
||||||
|
.encryption()
|
||||||
|
.wait_for_e2ee_initialization_tasks()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Save stored session once
|
||||||
|
client.save_stored_session().await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Automatically save session when token gets refreshed
|
||||||
|
client.setup_background_session_save().await;
|
||||||
|
|
||||||
|
Ok(client)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Destroy 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(email);
|
||||||
|
if passphrase_path.is_file() {
|
||||||
|
std::fs::remove_file(passphrase_path).map_err(MatrixClientError::ClearDbPassphrase)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initiate OAuth authentication
|
||||||
|
pub async fn initiate_login(&self) -> anyhow::Result<Url> {
|
||||||
|
let oauth = self.client.oauth();
|
||||||
|
|
||||||
|
let metadata = AppConfig::get().matrix_client_metadata();
|
||||||
|
let client_metadata = Raw::new(&metadata).expect("Couldn't serialize client metadata");
|
||||||
|
|
||||||
|
let auth = oauth
|
||||||
|
.login(
|
||||||
|
Url::parse(&AppConfig::get().matrix_oauth_redirect_url())
|
||||||
|
.map_err(MatrixClientError::ParseAuthRedirectURL)?,
|
||||||
|
None,
|
||||||
|
Some(client_metadata.into()),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
.await
|
||||||
|
.map_err(MatrixClientError::BuildAuthRequest)?;
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
164
matrixgw_backend/src/matrix_connection/matrix_manager.rs
Normal file
164
matrixgw_backend/src/matrix_connection/matrix_manager.rs
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
impl Actor for MatrixManagerActor {
|
||||||
|
type Msg = MatrixManagerMsg;
|
||||||
|
type State = MatrixManagerState;
|
||||||
|
type Arguments = BroadcastSender;
|
||||||
|
|
||||||
|
async fn pre_start(
|
||||||
|
&self,
|
||||||
|
_myself: ActorRef<Self::Msg>,
|
||||||
|
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>,
|
||||||
|
message: Self::Msg,
|
||||||
|
state: &mut Self::State,
|
||||||
|
) -> Result<(), ActorProcessingErr> {
|
||||||
|
match message {
|
||||||
|
// Get client information
|
||||||
|
MatrixManagerMsg::GetClient(email, port) => {
|
||||||
|
let res = port.send(match state.clients.get(&email) {
|
||||||
|
None => {
|
||||||
|
// Generate client if required
|
||||||
|
log::info!("Building new client for {:?}", &email);
|
||||||
|
match MatrixClient::build_client(myself, &email).await {
|
||||||
|
Ok(c) => {
|
||||||
|
state.clients.insert(email.clone(), c.clone());
|
||||||
|
Ok(c)
|
||||||
|
}
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(c) => Ok(c.clone()),
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Err(e) = res {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
3
matrixgw_backend/src/matrix_connection/mod.rs
Normal file
3
matrixgw_backend/src/matrix_connection/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod matrix_client;
|
||||||
|
pub mod matrix_manager;
|
||||||
|
pub mod sync_thread;
|
||||||
185
matrixgw_backend/src/matrix_connection/sync_thread.rs
Normal file
185
matrixgw_backend/src/matrix_connection/sync_thread.rs
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
//! # 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::receipt::SyncReceiptEvent;
|
||||||
|
use matrix_sdk::ruma::events::room::message::OriginalSyncRoomMessageEvent;
|
||||||
|
use matrix_sdk::ruma::events::room::redaction::OriginalSyncRoomRedactionEvent;
|
||||||
|
use matrix_sdk::ruma::events::typing::SyncTypingEvent;
|
||||||
|
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_mail = 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_mail.clone(),
|
||||||
|
data: Box::new(event),
|
||||||
|
room,
|
||||||
|
})) {
|
||||||
|
log::warn!("Failed to forward room message event! {e}");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
));
|
||||||
|
|
||||||
|
let tx_reac_handle = tx.clone();
|
||||||
|
let user_reac_mail = 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_mail.clone(),
|
||||||
|
data: Box::new(event),
|
||||||
|
room,
|
||||||
|
})) {
|
||||||
|
log::warn!("Failed to forward reaction event! {e}");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
));
|
||||||
|
|
||||||
|
let tx_redac_handle = tx.clone();
|
||||||
|
let user_redac_mail = 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_mail.clone(),
|
||||||
|
data: Box::new(event),
|
||||||
|
room,
|
||||||
|
}))
|
||||||
|
{
|
||||||
|
log::warn!("Failed to forward redaction event! {e}");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
));
|
||||||
|
|
||||||
|
let tx_receipt_handle = tx.clone();
|
||||||
|
let user_receipt_mail = client.email.clone();
|
||||||
|
handlers.push(
|
||||||
|
client.add_event_handler(async move |event: SyncReceiptEvent, room: Room| {
|
||||||
|
if let Err(e) = tx_receipt_handle.send(BroadcastMessage::ReceiptEvent(BxRoomEvent {
|
||||||
|
user: user_receipt_mail.clone(),
|
||||||
|
data: Box::new(event),
|
||||||
|
room,
|
||||||
|
})) {
|
||||||
|
log::warn!("Failed to forward receipt event! {e}");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let tx_typing_handle = tx.clone();
|
||||||
|
let user_typing_mail = client.email.clone();
|
||||||
|
handlers.push(
|
||||||
|
client.add_event_handler(async move |event: SyncTypingEvent, room: Room| {
|
||||||
|
if let Err(e) = tx_typing_handle.send(BroadcastMessage::TypingEvent(BxRoomEvent {
|
||||||
|
user: user_typing_mail.clone(),
|
||||||
|
data: Box::new(event),
|
||||||
|
room,
|
||||||
|
})) {
|
||||||
|
log::warn!("Failed to forward typing 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::app_config::AppConfig;
|
||||||
|
use crate::broadcast_messages::{BroadcastMessage, BroadcastSender};
|
||||||
|
use crate::constants;
|
||||||
|
use crate::controllers::server_controller::ServerConstraints;
|
||||||
|
use crate::matrix_connection::matrix_client::EncryptionRecoveryState;
|
||||||
|
use crate::utils::rand_utils::rand_string;
|
||||||
use crate::utils::time_utils::time_secs;
|
use crate::utils::time_utils::time_secs;
|
||||||
|
use anyhow::Context;
|
||||||
use jwt_simple::reexports::serde_json;
|
use jwt_simple::reexports::serde_json;
|
||||||
use std::cmp::min;
|
use std::cmp::min;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
@@ -13,6 +19,8 @@ enum MatrixGWUserError {
|
|||||||
DecodeUserMetadata(serde_json::Error),
|
DecodeUserMetadata(serde_json::Error),
|
||||||
#[error("Failed to save user metadata: {0}")]
|
#[error("Failed to save user metadata: {0}")]
|
||||||
SaveUserMetadata(std::io::Error),
|
SaveUserMetadata(std::io::Error),
|
||||||
|
#[error("Failed to create API token directory: {0}")]
|
||||||
|
CreateApiTokensDirectory(std::io::Error),
|
||||||
#[error("Failed to delete API token: {0}")]
|
#[error("Failed to delete API token: {0}")]
|
||||||
DeleteToken(std::io::Error),
|
DeleteToken(std::io::Error),
|
||||||
#[error("Failed to load API token: {0}")]
|
#[error("Failed to load API token: {0}")]
|
||||||
@@ -100,17 +108,63 @@ impl User {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Single API client information
|
/// Base API token information
|
||||||
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
|
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
|
||||||
pub struct APIToken {
|
pub struct BaseAPIToken {
|
||||||
/// Token unique ID
|
/// Token name
|
||||||
pub id: APITokenID,
|
pub name: String,
|
||||||
|
|
||||||
/// Client description
|
|
||||||
pub description: String,
|
|
||||||
|
|
||||||
/// Restricted API network for token
|
/// Restricted API network for token
|
||||||
pub network: Option<ipnet::IpNet>,
|
pub networks: Option<Vec<ipnet::IpNet>>,
|
||||||
|
|
||||||
|
/// Token max inactivity
|
||||||
|
pub max_inactivity: u32,
|
||||||
|
|
||||||
|
/// Token expiration
|
||||||
|
pub expiration: Option<u64>,
|
||||||
|
|
||||||
|
/// Read only access
|
||||||
|
pub read_only: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BaseAPIToken {
|
||||||
|
/// Check API token information validity
|
||||||
|
pub fn check(&self) -> Option<&'static str> {
|
||||||
|
let constraints = ServerConstraints::default();
|
||||||
|
|
||||||
|
if !lazy_regex::regex!("^[a-zA-Z0-9 :-]+$").is_match(&self.name) {
|
||||||
|
return Some("Token name contains invalid characters!");
|
||||||
|
}
|
||||||
|
|
||||||
|
if !constraints.token_name.check_str(&self.name) {
|
||||||
|
return Some("Invalid token name length!");
|
||||||
|
}
|
||||||
|
|
||||||
|
if !constraints
|
||||||
|
.token_max_inactivity
|
||||||
|
.check_u32(self.max_inactivity)
|
||||||
|
{
|
||||||
|
return Some("Invalid token max inactivity!");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(expiration) = self.expiration
|
||||||
|
&& expiration <= time_secs()
|
||||||
|
{
|
||||||
|
return Some("Given expiration time is in the past!");
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single API token information
|
||||||
|
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
|
||||||
|
pub struct APIToken {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub base: BaseAPIToken,
|
||||||
|
|
||||||
|
/// Token unique ID
|
||||||
|
pub id: APITokenID,
|
||||||
|
|
||||||
/// Client secret
|
/// Client secret
|
||||||
pub secret: String,
|
pub secret: String,
|
||||||
@@ -120,15 +174,58 @@ pub struct APIToken {
|
|||||||
|
|
||||||
/// Client last usage time
|
/// Client last usage time
|
||||||
pub last_used: u64,
|
pub last_used: u64,
|
||||||
|
|
||||||
/// Read only access
|
|
||||||
pub read_only: bool,
|
|
||||||
|
|
||||||
/// Token max inactivity
|
|
||||||
pub max_inactivity: u64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl APIToken {
|
impl APIToken {
|
||||||
|
/// Get the list of tokens of a user
|
||||||
|
pub async fn list_user(email: &UserEmail) -> anyhow::Result<Vec<Self>> {
|
||||||
|
let tokens_dir = AppConfig::get().user_api_token_directory(email);
|
||||||
|
|
||||||
|
if !tokens_dir.exists() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut list = vec![];
|
||||||
|
for u in std::fs::read_dir(&tokens_dir)? {
|
||||||
|
let entry = u?;
|
||||||
|
list.push(
|
||||||
|
Self::load(
|
||||||
|
email,
|
||||||
|
&APITokenID::from_str(
|
||||||
|
entry
|
||||||
|
.file_name()
|
||||||
|
.to_str()
|
||||||
|
.context("Cannot decode API Token ID as string!")?,
|
||||||
|
)?,
|
||||||
|
)
|
||||||
|
.await?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new token
|
||||||
|
pub async fn create(email: &UserEmail, base: BaseAPIToken) -> anyhow::Result<Self> {
|
||||||
|
let tokens_dir = AppConfig::get().user_api_token_directory(email);
|
||||||
|
|
||||||
|
if !tokens_dir.exists() {
|
||||||
|
std::fs::create_dir_all(tokens_dir)
|
||||||
|
.map_err(MatrixGWUserError::CreateApiTokensDirectory)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = APIToken {
|
||||||
|
base,
|
||||||
|
id: Default::default(),
|
||||||
|
secret: rand_string(constants::TOKENS_LEN),
|
||||||
|
created: time_secs(),
|
||||||
|
last_used: time_secs(),
|
||||||
|
};
|
||||||
|
|
||||||
|
token.write(email).await?;
|
||||||
|
|
||||||
|
Ok(token)
|
||||||
|
}
|
||||||
|
|
||||||
/// Get a token information
|
/// Get a token information
|
||||||
pub async fn load(email: &UserEmail, id: &APITokenID) -> anyhow::Result<Self> {
|
pub async fn load(email: &UserEmail, id: &APITokenID) -> anyhow::Result<Self> {
|
||||||
let token_file = AppConfig::get().user_api_token_metadata_file(email, id);
|
let token_file = AppConfig::get().user_api_token_metadata_file(email, id);
|
||||||
@@ -150,19 +247,42 @@ impl APIToken {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Delete this token
|
/// Delete this token
|
||||||
pub async fn delete(self, email: &UserEmail) -> anyhow::Result<()> {
|
pub async fn delete(self, email: &UserEmail, tx: &BroadcastSender) -> anyhow::Result<()> {
|
||||||
let token_file = AppConfig::get().user_api_token_metadata_file(email, &self.id);
|
let token_file = AppConfig::get().user_api_token_metadata_file(email, &self.id);
|
||||||
std::fs::remove_file(&token_file).map_err(MatrixGWUserError::DeleteToken)?;
|
std::fs::remove_file(&token_file).map_err(MatrixGWUserError::DeleteToken)?;
|
||||||
|
|
||||||
|
if let Err(e) = tx.send(BroadcastMessage::APITokenDeleted(self)) {
|
||||||
|
log::error!("Failed to notify API token deletion! {e}");
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn shall_update_time_used(&self) -> bool {
|
pub fn shall_update_time_used(&self) -> bool {
|
||||||
let refresh_interval = min(600, self.max_inactivity / 10);
|
let refresh_interval = min(600, self.base.max_inactivity / 10);
|
||||||
|
|
||||||
(self.last_used) < time_secs() - refresh_interval
|
(self.last_used) < time_secs() - refresh_interval as u64
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_expired(&self) -> bool {
|
pub fn is_expired(&self) -> bool {
|
||||||
(self.last_used + self.max_inactivity) < time_secs()
|
// Check for hard coded expiration
|
||||||
|
if let Some(exp_time) = self.base.expiration
|
||||||
|
&& exp_time < time_secs()
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Control max token inactivity
|
||||||
|
(self.last_used + self.base.max_inactivity as u64) < time_secs()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize, Debug, Clone)]
|
||||||
|
pub struct ExtendedUserInfo {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub user: User,
|
||||||
|
pub matrix_account_connected: bool,
|
||||||
|
pub matrix_user_id: Option<String>,
|
||||||
|
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
|
/// Compute SHA256sum of a given string
|
||||||
pub fn sha256str(input: &str) -> String {
|
pub fn sha256str(input: &str) -> String {
|
||||||
hex::encode(Sha256::digest(input.as_bytes()))
|
hex::encode(Sha256::digest(input.as_bytes()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compute SHA256sum of a given byte array
|
||||||
|
pub fn sha512(input: &[u8]) -> String {
|
||||||
|
hex::encode(Sha512::digest(input))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,73 +1,2 @@
|
|||||||
# React + TypeScript + Vite
|
# MatrixGW frontend
|
||||||
|
Built using React + TypeScript + Vite
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
|
||||||
|
|
||||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
|
||||||
|
|
||||||
## React Compiler
|
|
||||||
|
|
||||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
|
||||||
|
|
||||||
## Expanding the ESLint configuration
|
|
||||||
|
|
||||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
|
||||||
|
|
||||||
```js
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
|
|
||||||
// Remove tseslint.configs.recommended and replace with this
|
|
||||||
tseslint.configs.recommendedTypeChecked,
|
|
||||||
// Alternatively, use this for stricter rules
|
|
||||||
tseslint.configs.strictTypeChecked,
|
|
||||||
// Optionally, add this for stylistic rules
|
|
||||||
tseslint.configs.stylisticTypeChecked,
|
|
||||||
|
|
||||||
// Other configs...
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
|
||||||
|
|
||||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
|
||||||
|
|
||||||
```js
|
|
||||||
// eslint.config.js
|
|
||||||
import reactX from 'eslint-plugin-react-x'
|
|
||||||
import reactDom from 'eslint-plugin-react-dom'
|
|
||||||
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
// Enable lint rules for React
|
|
||||||
reactX.configs['recommended-typescript'],
|
|
||||||
// Enable lint rules for React DOM
|
|
||||||
reactDom.configs.recommended,
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
|
||||||
@@ -1,23 +1,26 @@
|
|||||||
import js from '@eslint/js'
|
import js from "@eslint/js";
|
||||||
import globals from 'globals'
|
import globals from "globals";
|
||||||
import reactHooks from 'eslint-plugin-react-hooks'
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
import reactRefresh from "eslint-plugin-react-refresh";
|
||||||
import tseslint from 'typescript-eslint'
|
import tseslint from "typescript-eslint";
|
||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
globalIgnores(['dist']),
|
globalIgnores(["dist"]),
|
||||||
{
|
{
|
||||||
files: ['**/*.{ts,tsx}'],
|
files: ["**/*.{ts,tsx}"],
|
||||||
extends: [
|
extends: [
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
tseslint.configs.recommended,
|
tseslint.configs.recommended,
|
||||||
reactHooks.configs['recommended-latest'],
|
reactHooks.configs.flat.recommended,
|
||||||
reactRefresh.configs.vite,
|
reactRefresh.configs.vite,
|
||||||
],
|
],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
globals: globals.browser,
|
globals: globals.browser,
|
||||||
},
|
},
|
||||||
|
rules: {
|
||||||
|
"react-refresh/only-export-components": "off",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
])
|
]);
|
||||||
|
|||||||
1311
matrixgw_frontend/package-lock.json
generated
1311
matrixgw_frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,30 +12,37 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@fontsource/roboto": "^5.2.8",
|
"@fontsource/roboto": "^5.2.9",
|
||||||
"@mdi/js": "^7.4.47",
|
"@mui/icons-material": "^7.3.7",
|
||||||
"@mdi/react": "^1.6.1",
|
"@mui/material": "^7.3.7",
|
||||||
"@mui/icons-material": "^7.3.5",
|
"@mui/x-data-grid": "^8.24.0",
|
||||||
"@mui/material": "^7.3.5",
|
"@mui/x-date-pickers": "^8.23.0",
|
||||||
"react": "^19.1.1",
|
"date-and-time": "^4.1.2",
|
||||||
"react-dom": "^19.1.1",
|
"dayjs": "^1.11.19",
|
||||||
"react-router": "^7.9.5"
|
"emoji-picker-react": "^4.16.1",
|
||||||
|
"is-cidr": "^6.0.1",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
|
"react": "^19.2.3",
|
||||||
|
"react-dom": "^19.2.3",
|
||||||
|
"react-favicon": "^2.0.7",
|
||||||
|
"react-json-view-lite": "^2.5.0",
|
||||||
|
"react-router": "^7.12.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.36.0",
|
"@eslint/js": "^9.39.2",
|
||||||
"@types/node": "^24.6.0",
|
"@types/node": "^24.10.8",
|
||||||
"@types/react": "^19.1.16",
|
"@types/react": "^19.2.8",
|
||||||
"@types/react-dom": "^19.1.9",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.1.2",
|
||||||
"eslint": "^9.36.0",
|
"eslint": "^9.39.2",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.22",
|
"eslint-plugin-react-refresh": "^0.4.26",
|
||||||
"globals": "^16.4.0",
|
"globals": "^16.5.0",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.45.0",
|
"typescript-eslint": "^8.51.0",
|
||||||
"vite": "npm:rolldown-vite@7.1.14"
|
"vite": "npm:rolldown-vite@7.3.1"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"vite": "npm:rolldown-vite@7.1.14"
|
"vite": "npm:rolldown-vite@7.3.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
matrixgw_frontend/public/favicon.png
Normal file
BIN
matrixgw_frontend/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -7,12 +7,16 @@ import {
|
|||||||
} from "react-router";
|
} from "react-router";
|
||||||
import { AuthApi } from "./api/AuthApi";
|
import { AuthApi } from "./api/AuthApi";
|
||||||
import { ServerApi } from "./api/ServerApi";
|
import { ServerApi } from "./api/ServerApi";
|
||||||
|
import { APITokensRoute } from "./routes/APITokensRoute";
|
||||||
import { LoginRoute } from "./routes/auth/LoginRoute";
|
import { LoginRoute } from "./routes/auth/LoginRoute";
|
||||||
import { OIDCCbRoute } from "./routes/auth/OIDCCbRoute";
|
import { OIDCCbRoute } from "./routes/auth/OIDCCbRoute";
|
||||||
import { HomeRoute } from "./routes/HomeRoute";
|
import { HomeRoute } from "./routes/HomeRoute";
|
||||||
|
import { MatrixAuthCallback } from "./routes/MatrixAuthCallback";
|
||||||
|
import { MatrixLinkRoute } from "./routes/MatrixLinkRoute";
|
||||||
import { NotFoundRoute } from "./routes/NotFoundRoute";
|
import { NotFoundRoute } from "./routes/NotFoundRoute";
|
||||||
import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage";
|
import { WSDebugRoute } from "./routes/WSDebugRoute";
|
||||||
import { BaseLoginPage } from "./widgets/auth/BaseLoginPage";
|
import { BaseLoginPage } from "./widgets/auth/BaseLoginPage";
|
||||||
|
import BaseAuthenticatedPage from "./widgets/dashboard/BaseAuthenticatedPage";
|
||||||
|
|
||||||
interface AuthContext {
|
interface AuthContext {
|
||||||
signedIn: boolean;
|
signedIn: boolean;
|
||||||
@@ -37,6 +41,10 @@ export function App(): React.ReactElement {
|
|||||||
signedIn || ServerApi.Config.auth_disabled ? (
|
signedIn || ServerApi.Config.auth_disabled ? (
|
||||||
<Route path="*" element={<BaseAuthenticatedPage />}>
|
<Route path="*" element={<BaseAuthenticatedPage />}>
|
||||||
<Route path="" element={<HomeRoute />} />
|
<Route path="" element={<HomeRoute />} />
|
||||||
|
<Route path="matrix_link" element={<MatrixLinkRoute />} />
|
||||||
|
<Route path="matrix_auth_cb" element={<MatrixAuthCallback />} />
|
||||||
|
<Route path="tokens" element={<APITokensRoute />} />
|
||||||
|
<Route path="wsdebug" element={<WSDebugRoute />} />
|
||||||
<Route path="*" element={<NotFoundRoute />} />
|
<Route path="*" element={<NotFoundRoute />} />
|
||||||
</Route>
|
</Route>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -4,21 +4,21 @@ interface RequestParams {
|
|||||||
uri: string;
|
uri: string;
|
||||||
method: "GET" | "POST" | "DELETE" | "PATCH" | "PUT";
|
method: "GET" | "POST" | "DELETE" | "PATCH" | "PUT";
|
||||||
allowFail?: boolean;
|
allowFail?: boolean;
|
||||||
jsonData?: any;
|
jsonData?: unknown;
|
||||||
formData?: FormData;
|
formData?: FormData;
|
||||||
upProgress?: (progress: number) => void;
|
upProgress?: (progress: number) => void;
|
||||||
downProgress?: (e: { progress: number; total: number }) => void;
|
downProgress?: (e: { progress: number; total: number }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface APIResponse {
|
interface APIResponse {
|
||||||
data: any;
|
data: unknown;
|
||||||
status: number;
|
status: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
public code: number;
|
public code: number;
|
||||||
public data: number;
|
public data: unknown;
|
||||||
constructor(message: string, code: number, data: any) {
|
constructor(message: string, code: number, data: unknown) {
|
||||||
super(`HTTP status: ${code}\nMessage: ${message}\nData=${data}`);
|
super(`HTTP status: ${code}\nMessage: ${message}\nData=${data}`);
|
||||||
this.code = code;
|
this.code = code;
|
||||||
this.data = data;
|
this.data = data;
|
||||||
@@ -57,6 +57,7 @@ export class APIClient {
|
|||||||
*/
|
*/
|
||||||
static async exec(args: RequestParams): Promise<APIResponse> {
|
static async exec(args: RequestParams): Promise<APIResponse> {
|
||||||
let body: string | undefined | FormData = undefined;
|
let body: string | undefined | FormData = undefined;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const headers: any = {};
|
const headers: any = {};
|
||||||
|
|
||||||
// JSON request
|
// JSON request
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { APIClient } from "./ApiClient";
|
import { APIClient } from "./ApiClient";
|
||||||
|
|
||||||
export interface AuthInfo {
|
export interface UserInfo {
|
||||||
id: number;
|
id: number;
|
||||||
time_create: number;
|
time_create: number;
|
||||||
time_update: number;
|
time_update: number;
|
||||||
name: string;
|
name: string;
|
||||||
email: 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";
|
const TokenStateKey = "auth-state";
|
||||||
@@ -41,7 +45,7 @@ export class AuthApi {
|
|||||||
uri: "/auth/start_oidc",
|
uri: "/auth/start_oidc",
|
||||||
method: "GET",
|
method: "GET",
|
||||||
})
|
})
|
||||||
).data;
|
).data as { url: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,15 +62,15 @@ export class AuthApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get auth information
|
* Get user information
|
||||||
*/
|
*/
|
||||||
static async GetAuthInfo(): Promise<AuthInfo> {
|
static async GetUserInfo(): Promise<UserInfo> {
|
||||||
return (
|
return (
|
||||||
await APIClient.exec({
|
await APIClient.exec({
|
||||||
uri: "/auth/info",
|
uri: "/auth/info",
|
||||||
method: "GET",
|
method: "GET",
|
||||||
})
|
})
|
||||||
).data;
|
).data as UserInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
47
matrixgw_frontend/src/api/MatrixLinkApi.ts
Normal file
47
matrixgw_frontend/src/api/MatrixLinkApi.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { APIClient } from "./ApiClient";
|
||||||
|
|
||||||
|
export class MatrixLinkApi {
|
||||||
|
/**
|
||||||
|
* Start Matrix Account login
|
||||||
|
*/
|
||||||
|
static async StartAuth(): Promise<{ url: string }> {
|
||||||
|
return (
|
||||||
|
await APIClient.exec({
|
||||||
|
uri: "/matrix_link/start_auth",
|
||||||
|
method: "POST",
|
||||||
|
})
|
||||||
|
).data as { url: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 as { started: boolean }).started;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ export class ServerApi {
|
|||||||
uri: "/server/config",
|
uri: "/server/config",
|
||||||
method: "GET",
|
method: "GET",
|
||||||
})
|
})
|
||||||
).data;
|
).data as ServerConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
56
matrixgw_frontend/src/api/TokensApi.ts
Normal file
56
matrixgw_frontend/src/api/TokensApi.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
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 as Token[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new token
|
||||||
|
*/
|
||||||
|
static async Create(t: BaseToken): Promise<TokenWithSecret> {
|
||||||
|
return (
|
||||||
|
await APIClient.exec({
|
||||||
|
uri: "/token",
|
||||||
|
method: "POST",
|
||||||
|
jsonData: t,
|
||||||
|
})
|
||||||
|
).data as TokenWithSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a token
|
||||||
|
*/
|
||||||
|
static async Delete(t: Token): Promise<void> {
|
||||||
|
await APIClient.exec({
|
||||||
|
uri: `/token/${t.id}`,
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
85
matrixgw_frontend/src/api/WsApi.ts
Normal file
85
matrixgw_frontend/src/api/WsApi.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { APIClient } from "./ApiClient";
|
||||||
|
import type { MessageType } from "./matrix/MatrixApiEvent";
|
||||||
|
|
||||||
|
interface BaseRoomEvent {
|
||||||
|
time: number;
|
||||||
|
room_id: string;
|
||||||
|
event_id: string;
|
||||||
|
sender: string;
|
||||||
|
origin_server_ts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoomMessageEvent extends BaseRoomEvent {
|
||||||
|
type: "RoomMessageEvent";
|
||||||
|
data: {
|
||||||
|
msgtype: MessageType;
|
||||||
|
body: string;
|
||||||
|
"m.relates_to"?: {
|
||||||
|
rel_type?: "m.replace" | string;
|
||||||
|
event_id?: string;
|
||||||
|
"m.in_reply_to"?:{
|
||||||
|
event_id?:string
|
||||||
|
}
|
||||||
|
};
|
||||||
|
"m.new_content"?: {
|
||||||
|
msgtype?: MessageType;
|
||||||
|
body?: string;
|
||||||
|
};
|
||||||
|
url?: string;
|
||||||
|
file?: { url: string };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoomReactionEvent extends BaseRoomEvent {
|
||||||
|
type: "RoomReactionEvent";
|
||||||
|
data: {
|
||||||
|
"m.relates_to": {
|
||||||
|
rel_type: string;
|
||||||
|
event_id: string;
|
||||||
|
key: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoomRedactionEvent extends BaseRoomEvent {
|
||||||
|
type: "RoomRedactionEvent";
|
||||||
|
data: {
|
||||||
|
redacts: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReceiptEventEntry {
|
||||||
|
event: string;
|
||||||
|
user: string;
|
||||||
|
ts?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoomReceiptEvent {
|
||||||
|
time: number;
|
||||||
|
type: "ReceiptEvent";
|
||||||
|
room_id: string;
|
||||||
|
receipts: ReceiptEventEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoomTypingEvent {
|
||||||
|
time: number;
|
||||||
|
type: "TypingEvent";
|
||||||
|
room_id: string;
|
||||||
|
user_ids: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WsMessage =
|
||||||
|
| RoomMessageEvent
|
||||||
|
| RoomReactionEvent
|
||||||
|
| RoomRedactionEvent
|
||||||
|
| RoomReceiptEvent
|
||||||
|
| RoomTypingEvent;
|
||||||
|
|
||||||
|
export class WsApi {
|
||||||
|
/**
|
||||||
|
* Get WebSocket URL
|
||||||
|
*/
|
||||||
|
static get WsURL(): string {
|
||||||
|
return APIClient.backendURL() + "/ws";
|
||||||
|
}
|
||||||
|
}
|
||||||
156
matrixgw_frontend/src/api/matrix/MatrixApiEvent.ts
Normal file
156
matrixgw_frontend/src/api/matrix/MatrixApiEvent.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { APIClient } from "../ApiClient";
|
||||||
|
import type { Room } from "./MatrixApiRoom";
|
||||||
|
|
||||||
|
export type MessageType =
|
||||||
|
| "m.text"
|
||||||
|
| "m.image"
|
||||||
|
| "m.audio"
|
||||||
|
| "m.file"
|
||||||
|
| "m.video"
|
||||||
|
| "_OTHER_";
|
||||||
|
|
||||||
|
export interface MatrixRoomMessage {
|
||||||
|
type: "m.room.message";
|
||||||
|
content: {
|
||||||
|
body: string;
|
||||||
|
msgtype: MessageType;
|
||||||
|
"m.relates_to"?: {
|
||||||
|
event_id?: string;
|
||||||
|
rel_type?: "m.replace" | string;
|
||||||
|
"m.in_reply_to"?: {
|
||||||
|
event_id?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
url?: 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?limit=200` +
|
||||||
|
(from ? `&from=${from}` : ""),
|
||||||
|
})
|
||||||
|
).data as MatrixEventsList;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Matrix event file URL
|
||||||
|
*/
|
||||||
|
static GetEventFileURL(
|
||||||
|
room: Room,
|
||||||
|
event_id: string,
|
||||||
|
thumbnail: boolean
|
||||||
|
): string {
|
||||||
|
return `${APIClient.ActualBackendURL()}/matrix/room/${
|
||||||
|
room.id
|
||||||
|
}/event/${event_id}/file?thumbnail=${thumbnail}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send text message
|
||||||
|
*/
|
||||||
|
static async SendTextMessage(room: Room, content: string): Promise<void> {
|
||||||
|
await APIClient.exec({
|
||||||
|
method: "POST",
|
||||||
|
uri: `/matrix/room/${room.id}/send_text_message`,
|
||||||
|
jsonData: { content },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit text message content
|
||||||
|
*/
|
||||||
|
static async SetTextMessageContent(
|
||||||
|
room: Room,
|
||||||
|
event_id: string,
|
||||||
|
content: string
|
||||||
|
): Promise<void> {
|
||||||
|
await APIClient.exec({
|
||||||
|
method: "POST",
|
||||||
|
uri: `/matrix/room/${room.id}/event/${event_id}/set_text_content`,
|
||||||
|
jsonData: { content },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React to event
|
||||||
|
*/
|
||||||
|
static async ReactToEvent(
|
||||||
|
room: Room,
|
||||||
|
event_id: string,
|
||||||
|
key: string
|
||||||
|
): Promise<void> {
|
||||||
|
await APIClient.exec({
|
||||||
|
method: "POST",
|
||||||
|
uri: `/matrix/room/${room.id}/event/${event_id}/react`,
|
||||||
|
jsonData: { key },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an event
|
||||||
|
*/
|
||||||
|
static async DeleteEvent(room: Room, event_id: string): Promise<void> {
|
||||||
|
await APIClient.exec({
|
||||||
|
method: "DELETE",
|
||||||
|
uri: `/matrix/room/${room.id}/event/${event_id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send event receipt
|
||||||
|
*/
|
||||||
|
static async SendReceipt(room: Room, event_id: string): Promise<void> {
|
||||||
|
await APIClient.exec({
|
||||||
|
method: "POST",
|
||||||
|
uri: `/matrix/room/${room.id}/event/${event_id}/receipt`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
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 = (
|
||||||
|
await APIClient.exec({
|
||||||
|
method: "POST",
|
||||||
|
uri: "/matrix/profile/get_multiple",
|
||||||
|
jsonData: ids,
|
||||||
|
})
|
||||||
|
).data as UserProfile[];
|
||||||
|
|
||||||
|
return new Map(list.map((e) => [e.user_id, e]));
|
||||||
|
}
|
||||||
|
}
|
||||||
74
matrixgw_frontend/src/api/matrix/MatrixApiRoom.ts
Normal file
74
matrixgw_frontend/src/api/matrix/MatrixApiRoom.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
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;
|
||||||
|
notifications: "AllMessages" | "MentionsAndKeywordsOnly" | "Mute";
|
||||||
|
latest_event?: MatrixEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Receipt {
|
||||||
|
user: string;
|
||||||
|
event_id: string;
|
||||||
|
ts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 as Room[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a room receipts
|
||||||
|
*/
|
||||||
|
static async RoomReceipts(room: Room): Promise<Receipt[]> {
|
||||||
|
return (
|
||||||
|
await APIClient.exec({
|
||||||
|
method: "GET",
|
||||||
|
uri: `/matrix/room/${room.id}/receipts`,
|
||||||
|
})
|
||||||
|
).data as Receipt[];
|
||||||
|
}
|
||||||
|
}
|
||||||
40
matrixgw_frontend/src/api/matrix/MatrixApiSpace.ts
Normal file
40
matrixgw_frontend/src/api/matrix/MatrixApiSpace.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { APIClient } from "../ApiClient";
|
||||||
|
|
||||||
|
export type SpaceHierarchy = Map<string, string[]>;
|
||||||
|
|
||||||
|
export class MatrixApiSpace {
|
||||||
|
/**
|
||||||
|
* Request Matrix space hierarchy
|
||||||
|
*/
|
||||||
|
static async Hierarchy(): Promise<SpaceHierarchy> {
|
||||||
|
const hierarchy = new Map(
|
||||||
|
Object.entries(
|
||||||
|
(
|
||||||
|
await APIClient.exec({
|
||||||
|
method: "GET",
|
||||||
|
uri: "/matrix/space/hierarchy",
|
||||||
|
})
|
||||||
|
).data as { [s: string]: string[] }
|
||||||
|
)
|
||||||
|
) as SpaceHierarchy;
|
||||||
|
|
||||||
|
// Simplify hierarchy
|
||||||
|
while (true) {
|
||||||
|
let changed = false;
|
||||||
|
for (const [roomid, children] of hierarchy) {
|
||||||
|
for (const child of children) {
|
||||||
|
if (!hierarchy.has(child)) continue;
|
||||||
|
hierarchy.set(roomid, [
|
||||||
|
...hierarchy.get(roomid)!,
|
||||||
|
...hierarchy.get(child)!,
|
||||||
|
]);
|
||||||
|
hierarchy.delete(child);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!changed) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hierarchy;
|
||||||
|
}
|
||||||
|
}
|
||||||
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(
|
export function LoadingMessageProvider(
|
||||||
p: PropsWithChildren
|
p: PropsWithChildren
|
||||||
): React.ReactElement {
|
): React.ReactElement {
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(0);
|
||||||
|
|
||||||
const [message, setMessage] = React.useState("");
|
const [message, setMessage] = React.useState("");
|
||||||
|
|
||||||
const hook: LoadingMessageContext = {
|
const hook: LoadingMessageContext = {
|
||||||
show(message) {
|
show(message) {
|
||||||
setMessage(message);
|
setMessage(message);
|
||||||
setOpen(true);
|
setOpen((v) => v + 1);
|
||||||
},
|
},
|
||||||
hide() {
|
hide() {
|
||||||
setMessage("");
|
setOpen((v) => v - 1);
|
||||||
setOpen(false);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -36,7 +35,7 @@ export function LoadingMessageProvider(
|
|||||||
<>
|
<>
|
||||||
<LoadingMessageContextK value={hook}>{p.children}</LoadingMessageContextK>
|
<LoadingMessageContextK value={hook}>{p.children}</LoadingMessageContextK>
|
||||||
|
|
||||||
<Dialog open={open}>
|
<Dialog open={open > 0}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogContentText>
|
<DialogContentText>
|
||||||
<div
|
<div
|
||||||
|
|||||||
18
matrixgw_frontend/src/icons/AppIcon.tsx
Normal file
18
matrixgw_frontend/src/icons/AppIcon.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Icon } from "@mui/material";
|
||||||
|
import { useActualColorMode } from "../widgets/dashboard/ThemeSwitcher";
|
||||||
|
|
||||||
|
export function AppIcon(p: { src: string; size?: string }): React.ReactElement {
|
||||||
|
const { mode } = useActualColorMode();
|
||||||
|
return (
|
||||||
|
<Icon style={{ display: "inline-flex", width: p.size, height: p.size }}>
|
||||||
|
<img
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: mode === "dark" ? "white" : "black",
|
||||||
|
mask: `url("${p.src}")`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
matrixgw_frontend/src/icons/message-text-fast.svg
Normal file
1
matrixgw_frontend/src/icons/message-text-fast.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M9 5C7.9 5 7 5.9 7 7V21L11 17H20C21.1 17 22 16.1 22 15V7C22 5.9 21.1 5 20 5H9M3 7C2.4 7 2 7.4 2 8S2.4 9 3 9H5V7H3M11 8H19V10H11V8M2 11C1.4 11 1 11.4 1 12S1.4 13 2 13H5V11H2M11 12H16V14H11V12M1 15C.4 15 0 15.4 0 16C0 16.6 .4 17 1 17H5V15H1Z" /></svg>
|
||||||
|
After Width: | Height: | Size: 318 B |
1
matrixgw_frontend/src/icons/openid.svg
Normal file
1
matrixgw_frontend/src/icons/openid.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M14,2L11,3.5V19.94C7,19.5 4,17.46 4,15C4,12.75 6.5,10.85 10,10.22V8.19C4.86,8.88 1,11.66 1,15C1,18.56 5.36,21.5 11,21.94C11.03,21.94 11.06,21.94 11.09,21.94L14,20.5V2M15,8.19V10.22C16.15,10.43 17.18,10.77 18.06,11.22L16.5,12L23,13.5L22.5,9L20.5,10C19,9.12 17.12,8.47 15,8.19Z" /></svg>
|
||||||
|
After Width: | Height: | Size: 354 B |
@@ -7,3 +7,12 @@ body,
|
|||||||
#root {
|
#root {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root > div {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,39 +3,43 @@ import "@fontsource/roboto/400.css";
|
|||||||
import "@fontsource/roboto/500.css";
|
import "@fontsource/roboto/500.css";
|
||||||
import "@fontsource/roboto/700.css";
|
import "@fontsource/roboto/700.css";
|
||||||
|
|
||||||
|
import { CssBaseline } from "@mui/material";
|
||||||
|
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||||
|
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import "./index.css";
|
import { ServerApi } from "./api/ServerApi";
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
import { AlertDialogProvider } from "./hooks/contexts_provider/AlertDialogProvider";
|
import { AlertDialogProvider } from "./hooks/contexts_provider/AlertDialogProvider";
|
||||||
import { ConfirmDialogProvider } from "./hooks/contexts_provider/ConfirmDialogProvider";
|
import { ConfirmDialogProvider } from "./hooks/contexts_provider/ConfirmDialogProvider";
|
||||||
import { SnackbarProvider } from "./hooks/contexts_provider/SnackbarProvider";
|
|
||||||
import { LoadingMessageProvider } from "./hooks/contexts_provider/LoadingMessageProvider";
|
import { LoadingMessageProvider } from "./hooks/contexts_provider/LoadingMessageProvider";
|
||||||
import { AsyncWidget } from "./widgets/AsyncWidget";
|
import { SnackbarProvider } from "./hooks/contexts_provider/SnackbarProvider";
|
||||||
import { ServerApi } from "./api/ServerApi";
|
import "./index.css";
|
||||||
import { AppTheme } from "./theme/AppTheme";
|
import { AppTheme } from "./theme/AppTheme";
|
||||||
import { CssBaseline } from "@mui/material";
|
import { AsyncWidget } from "./widgets/AsyncWidget";
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<AppTheme>
|
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="en">
|
||||||
<CssBaseline enableColorScheme />
|
<AppTheme>
|
||||||
<AlertDialogProvider>
|
<CssBaseline enableColorScheme />
|
||||||
<ConfirmDialogProvider>
|
<AlertDialogProvider>
|
||||||
<SnackbarProvider>
|
<ConfirmDialogProvider>
|
||||||
<LoadingMessageProvider>
|
<SnackbarProvider>
|
||||||
<AsyncWidget
|
<LoadingMessageProvider>
|
||||||
loadKey={1}
|
<AsyncWidget
|
||||||
load={async () => {
|
loadKey={1}
|
||||||
await ServerApi.LoadConfig();
|
load={async () => {
|
||||||
}}
|
await ServerApi.LoadConfig();
|
||||||
errMsg="Failed to load static server configuration!"
|
}}
|
||||||
build={() => <App />}
|
errMsg="Failed to load static server configuration!"
|
||||||
/>
|
build={() => <App />}
|
||||||
</LoadingMessageProvider>
|
/>
|
||||||
</SnackbarProvider>
|
</LoadingMessageProvider>
|
||||||
</ConfirmDialogProvider>
|
</SnackbarProvider>
|
||||||
</AlertDialogProvider>
|
</ConfirmDialogProvider>
|
||||||
</AppTheme>
|
</AlertDialogProvider>
|
||||||
|
</AppTheme>
|
||||||
|
</LocalizationProvider>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
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, setCount] = React.useState(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 = () => {
|
||||||
|
setCount((c) => c + 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}
|
||||||
|
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,3 +1,11 @@
|
|||||||
|
import { useUserInfo } from "../widgets/dashboard/BaseAuthenticatedPage";
|
||||||
|
import { MainMessageWidget } from "../widgets/messages/MainMessagesWidget";
|
||||||
|
import { NotLinkedAccountMessage } from "../widgets/NotLinkedAccountMessage";
|
||||||
|
|
||||||
export function HomeRoute(): React.ReactElement {
|
export function HomeRoute(): React.ReactElement {
|
||||||
return <p>Todo home route</p>;
|
const user = useUserInfo();
|
||||||
|
|
||||||
|
if (!user.info.matrix_account_connected) return <NotLinkedAccountMessage />;
|
||||||
|
|
||||||
|
return <MainMessageWidget />;
|
||||||
}
|
}
|
||||||
|
|||||||
82
matrixgw_frontend/src/routes/MatrixAuthCallback.tsx
Normal file
82
matrixgw_frontend/src/routes/MatrixAuthCallback.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
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();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [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>
|
||||||
|
);
|
||||||
|
}
|
||||||
329
matrixgw_frontend/src/routes/MatrixLinkRoute.tsx
Normal file
329
matrixgw_frontend/src/routes/MatrixLinkRoute.tsx
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
export function MatrixLinkRoute(): React.ReactElement {
|
||||||
|
const user = useUserInfo();
|
||||||
|
return (
|
||||||
|
<MatrixGWRouteContainer label={"Matrix account link"}>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConnectCard(): React.ReactElement {
|
||||||
|
const alert = useAlert();
|
||||||
|
const loadingMessage = useLoadingMessage();
|
||||||
|
|
||||||
|
const startMatrixConnection = async () => {
|
||||||
|
try {
|
||||||
|
loadingMessage.show("Initiating Matrix link...");
|
||||||
|
|
||||||
|
const res = await MatrixLinkApi.StartAuth();
|
||||||
|
|
||||||
|
window.location.href = res.url;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to connect to Matrix account! ${e}`);
|
||||||
|
alert(`Failed to connect to Matrix account! ${e}`);
|
||||||
|
} finally {
|
||||||
|
loadingMessage.hide();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h5" component="div" gutterBottom>
|
||||||
|
<i>Disconnected from your Matrix account</i>
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="body1" gutterBottom>
|
||||||
|
You need to connect MatrixGW to your Matrix account to let it access
|
||||||
|
your messages.
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
<CardActions>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<LinkIcon />}
|
||||||
|
onClick={startMatrixConnection}
|
||||||
|
>
|
||||||
|
Connect now
|
||||||
|
</Button>
|
||||||
|
</CardActions>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 style={{ marginBottom: "10px" }}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h5" component="div" gutterBottom>
|
||||||
|
<i>Connected to your Matrix account</i>
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="body1" gutterBottom>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
<CardActions>
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
matrixgw_frontend/src/routes/WSDebugRoute.tsx
Normal file
56
matrixgw_frontend/src/routes/WSDebugRoute.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { JsonView, darkStyles } from "react-json-view-lite";
|
||||||
|
import "react-json-view-lite/dist/index.css";
|
||||||
|
import { type WsMessage } from "../api/WsApi";
|
||||||
|
import { useUserInfo } from "../widgets/dashboard/BaseAuthenticatedPage";
|
||||||
|
import { MatrixGWRouteContainer } from "../widgets/MatrixGWRouteContainer";
|
||||||
|
import { MatrixWS, WSState } from "../widgets/messages/MatrixWS";
|
||||||
|
import { NotLinkedAccountMessage } from "../widgets/NotLinkedAccountMessage";
|
||||||
|
|
||||||
|
type TimestampedMessages = WsMessage & { time: number };
|
||||||
|
|
||||||
|
export function WSDebugRoute(): React.ReactElement {
|
||||||
|
const user = useUserInfo();
|
||||||
|
|
||||||
|
const [state, setState] = React.useState<string>(WSState.Closed);
|
||||||
|
const [messages, setMessages] = React.useState<TimestampedMessages[]>([]);
|
||||||
|
|
||||||
|
const handleMessage = (msg: WsMessage) => {
|
||||||
|
setMessages((l) => [...l, msg]);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!user.info.matrix_account_connected) return <NotLinkedAccountMessage />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MatrixGWRouteContainer label={"WebSocket Debug"}>
|
||||||
|
{/* Status bar */}
|
||||||
|
<div style={{ display: "flex", alignItems: "center" }}>
|
||||||
|
<span style={{ marginRight: "0.5em" }}>State: </span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginRight: "0.5em",
|
||||||
|
color: state == WSState.Connected ? "green" : "red",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{state}
|
||||||
|
</span>
|
||||||
|
<MatrixWS onStateChange={setState} onMessage={handleMessage} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* WS messages list */}
|
||||||
|
{messages.map((msg, id) => (
|
||||||
|
<div style={{ margin: "10px", backgroundColor: "black" }}>
|
||||||
|
<JsonView
|
||||||
|
key={id}
|
||||||
|
data={msg}
|
||||||
|
shouldExpandNode={(level) => level < 2}
|
||||||
|
style={{
|
||||||
|
...darkStyles,
|
||||||
|
container: "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</MatrixGWRouteContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Alert, Box, Button, CircularProgress } from "@mui/material";
|
import { Alert, Box, Button, CircularProgress } from "@mui/material";
|
||||||
import Icon from "@mdi/react";
|
|
||||||
import { mdiOpenid } from "@mdi/js";
|
|
||||||
import { ServerApi } from "../../api/ServerApi";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { AuthApi } from "../../api/AuthApi";
|
import { AuthApi } from "../../api/AuthApi";
|
||||||
|
import { ServerApi } from "../../api/ServerApi";
|
||||||
|
import { AppIcon } from "../../icons/AppIcon";
|
||||||
|
import openid from "../../icons/openid.svg";
|
||||||
|
|
||||||
export function LoginRoute(): React.ReactElement {
|
export function LoginRoute(): React.ReactElement {
|
||||||
const [loading, setLoading] = React.useState(false);
|
const [loading, setLoading] = React.useState(false);
|
||||||
@@ -40,7 +40,7 @@ export function LoginRoute(): React.ReactElement {
|
|||||||
fullWidth
|
fullWidth
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={authWithOpenID}
|
onClick={authWithOpenID}
|
||||||
startIcon={<Icon path={mdiOpenid} size={1} />}
|
startIcon={<AppIcon src={openid} />}
|
||||||
>
|
>
|
||||||
Sign in with {ServerApi.Config.oidc_provider_name}
|
Sign in with {ServerApi.Config.oidc_provider_name}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { svgIconClasses } from "@mui/material/SvgIcon";
|
|||||||
import { typographyClasses } from "@mui/material/Typography";
|
import { typographyClasses } from "@mui/material/Typography";
|
||||||
import { gray, green, red } from "../themePrimitives";
|
import { gray, green, red } from "../themePrimitives";
|
||||||
|
|
||||||
/* eslint-disable import/prefer-default-export */
|
|
||||||
export const dataDisplayCustomizations: Components<Theme> = {
|
export const dataDisplayCustomizations: Components<Theme> = {
|
||||||
MuiList: {
|
MuiList: {
|
||||||
styleOverrides: {
|
styleOverrides: {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { type Theme, alpha, type Components } from "@mui/material/styles";
|
import { type Theme, alpha, type Components } from "@mui/material/styles";
|
||||||
import { gray, orange } from "../themePrimitives";
|
import { gray, orange } from "../themePrimitives";
|
||||||
|
|
||||||
/* eslint-disable import/prefer-default-export */
|
|
||||||
export const feedbackCustomizations: Components<Theme> = {
|
export const feedbackCustomizations: Components<Theme> = {
|
||||||
MuiAlert: {
|
MuiAlert: {
|
||||||
styleOverrides: {
|
styleOverrides: {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import CheckRoundedIcon from "@mui/icons-material/CheckRounded";
|
|||||||
import RemoveRoundedIcon from "@mui/icons-material/RemoveRounded";
|
import RemoveRoundedIcon from "@mui/icons-material/RemoveRounded";
|
||||||
import { gray, brand } from "../themePrimitives";
|
import { gray, brand } from "../themePrimitives";
|
||||||
|
|
||||||
/* eslint-disable import/prefer-default-export */
|
|
||||||
export const inputsCustomizations: Components<Theme> = {
|
export const inputsCustomizations: Components<Theme> = {
|
||||||
MuiButtonBase: {
|
MuiButtonBase: {
|
||||||
defaultProps: {
|
defaultProps: {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { tabClasses } from "@mui/material/Tab";
|
|||||||
import UnfoldMoreRoundedIcon from "@mui/icons-material/UnfoldMoreRounded";
|
import UnfoldMoreRoundedIcon from "@mui/icons-material/UnfoldMoreRounded";
|
||||||
import { gray, brand } from "../themePrimitives";
|
import { gray, brand } from "../themePrimitives";
|
||||||
|
|
||||||
/* eslint-disable import/prefer-default-export */
|
|
||||||
export const navigationCustomizations: Components<Theme> = {
|
export const navigationCustomizations: Components<Theme> = {
|
||||||
MuiMenuItem: {
|
MuiMenuItem: {
|
||||||
styleOverrides: {
|
styleOverrides: {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { alpha, type Theme, type Components } from "@mui/material/styles";
|
import { alpha, type Theme, type Components } from "@mui/material/styles";
|
||||||
import { gray } from "../themePrimitives";
|
import { gray } from "../themePrimitives";
|
||||||
|
|
||||||
/* eslint-disable import/prefer-default-export */
|
|
||||||
export const surfacesCustomizations: Components<Theme> = {
|
export const surfacesCustomizations: Components<Theme> = {
|
||||||
MuiAccordion: {
|
MuiAccordion: {
|
||||||
defaultProps: {
|
defaultProps: {
|
||||||
|
|||||||
@@ -24,8 +24,6 @@ declare module "@mui/material/styles" {
|
|||||||
900: string;
|
900: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PaletteColor extends ColorRange {}
|
|
||||||
|
|
||||||
interface Palette {
|
interface Palette {
|
||||||
baseShadow: string;
|
baseShadow: string;
|
||||||
}
|
}
|
||||||
@@ -405,10 +403,10 @@ export const shape = {
|
|||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
};
|
};
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
const defaultShadows: Shadows = [
|
const defaultShadows: Shadows = [
|
||||||
"none",
|
"none",
|
||||||
"var(--template-palette-baseShadow)",
|
"var(--template-palette-baseShadow)",
|
||||||
...defaultTheme.shadows.slice(2),
|
...defaultTheme.shadows.slice(2),
|
||||||
];
|
] as never;
|
||||||
|
|
||||||
export const shadows = defaultShadows;
|
export const shadows = defaultShadows;
|
||||||
|
|||||||
78
matrixgw_frontend/src/utils/DateUtils.ts
Normal file
78
matrixgw_frontend/src/utils/DateUtils.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { format } from "date-and-time";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get UNIX time
|
||||||
|
*
|
||||||
|
* @returns Number of seconds since Epoch
|
||||||
|
*/
|
||||||
|
export function time(): number {
|
||||||
|
return Math.floor(new Date().getTime() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get UNIX time
|
||||||
|
*
|
||||||
|
* @returns Number of milliseconds since Epoch
|
||||||
|
*/
|
||||||
|
export function timeMs(): number {
|
||||||
|
return new Date().getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
241
matrixgw_frontend/src/utils/RoomEventsManager.ts
Normal file
241
matrixgw_frontend/src/utils/RoomEventsManager.ts
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
import dayjs from "dayjs";
|
||||||
|
import type {
|
||||||
|
MatrixEvent,
|
||||||
|
MatrixEventData,
|
||||||
|
MatrixEventsList,
|
||||||
|
MessageType,
|
||||||
|
} from "../api/matrix/MatrixApiEvent";
|
||||||
|
import type { Receipt, Room } from "../api/matrix/MatrixApiRoom";
|
||||||
|
import type { WsMessage } from "../api/WsApi";
|
||||||
|
import { timeMs } from "./DateUtils";
|
||||||
|
|
||||||
|
export interface MessageReaction {
|
||||||
|
event_id: string;
|
||||||
|
account: string;
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
event_id: string;
|
||||||
|
account: string;
|
||||||
|
time_sent: number;
|
||||||
|
time_sent_dayjs: dayjs.Dayjs;
|
||||||
|
modified: boolean;
|
||||||
|
inReplyTo?: string;
|
||||||
|
reactions: Map<string, MessageReaction[]>;
|
||||||
|
content: string;
|
||||||
|
type: MessageType;
|
||||||
|
file?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RoomEventsManager {
|
||||||
|
readonly room: Room;
|
||||||
|
private events: MatrixEvent[];
|
||||||
|
private receipts: Receipt[];
|
||||||
|
messages: Message[];
|
||||||
|
endToken?: string;
|
||||||
|
typingUsers: string[];
|
||||||
|
receiptsEventsMap: Map<string, Receipt[]>;
|
||||||
|
|
||||||
|
get canLoadOlder(): boolean {
|
||||||
|
return !!this.endToken && this.events.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
room: Room,
|
||||||
|
initialMessages: MatrixEventsList,
|
||||||
|
receipts: Receipt[]
|
||||||
|
) {
|
||||||
|
this.room = room;
|
||||||
|
this.events = [];
|
||||||
|
this.receipts = receipts;
|
||||||
|
this.messages = [];
|
||||||
|
this.typingUsers = [];
|
||||||
|
this.receiptsEventsMap = new Map();
|
||||||
|
|
||||||
|
this.processNewEvents(initialMessages);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process events given by the API
|
||||||
|
*/
|
||||||
|
processNewEvents(evts: MatrixEventsList) {
|
||||||
|
this.endToken = evts.end;
|
||||||
|
this.events = [...this.events, ...evts.events];
|
||||||
|
this.rebuildMessagesList();
|
||||||
|
}
|
||||||
|
|
||||||
|
processWsMessage(m: WsMessage) {
|
||||||
|
if (m.room_id !== this.room.id) {
|
||||||
|
console.debug("Not an event for current room.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: MatrixEventData;
|
||||||
|
if (m.type === "RoomReactionEvent") {
|
||||||
|
data = {
|
||||||
|
type: "m.reaction",
|
||||||
|
content: {
|
||||||
|
"m.relates_to": {
|
||||||
|
key: m.data["m.relates_to"].key,
|
||||||
|
event_id: m.data["m.relates_to"].event_id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else if (m.type === "RoomRedactionEvent") {
|
||||||
|
data = {
|
||||||
|
type: "m.room.redaction",
|
||||||
|
redacts: m.data.redacts,
|
||||||
|
};
|
||||||
|
} else if (m.type === "RoomMessageEvent") {
|
||||||
|
data = {
|
||||||
|
type: "m.room.message",
|
||||||
|
content: {
|
||||||
|
body: m.data["m.new_content"]?.body ?? m.data.body,
|
||||||
|
msgtype: m.data.msgtype,
|
||||||
|
"m.relates_to": m.data["m.relates_to"],
|
||||||
|
url: m.data.url,
|
||||||
|
file: m.data.file,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else if (m.type === "ReceiptEvent") {
|
||||||
|
for (const r of m.receipts) {
|
||||||
|
const prevReceipt = this.receipts.find(
|
||||||
|
(needle) => r.user === needle.user
|
||||||
|
);
|
||||||
|
// Create new receipt
|
||||||
|
if (!prevReceipt)
|
||||||
|
this.receipts.push({
|
||||||
|
user: r.user,
|
||||||
|
event_id: r.event,
|
||||||
|
ts: r.ts ?? timeMs(),
|
||||||
|
});
|
||||||
|
// Update receipt
|
||||||
|
else {
|
||||||
|
prevReceipt.event_id = r.event;
|
||||||
|
prevReceipt.ts = r.ts ?? timeMs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.rebuildMessagesList();
|
||||||
|
return true; // Emphemeral event
|
||||||
|
} else if (m.type === "TypingEvent") {
|
||||||
|
this.typingUsers = m.user_ids;
|
||||||
|
return true; // Not a real event
|
||||||
|
} else {
|
||||||
|
// Ignore event
|
||||||
|
console.info("Event not supported => ignored");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.events.push({
|
||||||
|
sender: m.sender,
|
||||||
|
id: m.event_id,
|
||||||
|
time: m.origin_server_ts,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.rebuildMessagesList();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private rebuildMessagesList() {
|
||||||
|
this.messages = [];
|
||||||
|
|
||||||
|
// Sorts events list to process oldest events first
|
||||||
|
this.events.sort((a, b) => a.time - b.time);
|
||||||
|
|
||||||
|
// Process receipts (users map)
|
||||||
|
const receiptsUsersMap = new Map<string, Receipt>();
|
||||||
|
for (const r of this.receipts) {
|
||||||
|
receiptsUsersMap.set(r.user, { ...r });
|
||||||
|
}
|
||||||
|
|
||||||
|
// First, process redactions to skip redacted events
|
||||||
|
const 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"]?.rel_type === "replace") {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Else it is a new message; update receipts if needed
|
||||||
|
else {
|
||||||
|
const userReceipt = receiptsUsersMap.get(evt.sender);
|
||||||
|
|
||||||
|
// Create fake receipt if none is available
|
||||||
|
if (!userReceipt)
|
||||||
|
receiptsUsersMap.set(evt.sender, {
|
||||||
|
event_id: evt.id,
|
||||||
|
ts: evt.time,
|
||||||
|
user: evt.sender,
|
||||||
|
});
|
||||||
|
// If the message is more recent than user receipt, replace the receipt
|
||||||
|
else if (userReceipt.ts < evt.time) {
|
||||||
|
userReceipt.event_id = evt.id;
|
||||||
|
userReceipt.ts = evt.time;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.messages.push({
|
||||||
|
event_id: evt.id,
|
||||||
|
account: evt.sender,
|
||||||
|
modified: false,
|
||||||
|
inReplyTo: data.content["m.relates_to"]?.["m.in_reply_to"]?.event_id,
|
||||||
|
reactions: new Map(),
|
||||||
|
time_sent: evt.time,
|
||||||
|
time_sent_dayjs: dayjs.unix(evt.time / 1000),
|
||||||
|
type: data.content.msgtype,
|
||||||
|
file: data.content.file?.url ?? data.content.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
|
||||||
|
);
|
||||||
|
const key = data.content["m.relates_to"].key;
|
||||||
|
|
||||||
|
if (!message) continue;
|
||||||
|
|
||||||
|
if (!message.reactions.has(key)) message.reactions.set(key, []);
|
||||||
|
|
||||||
|
message.reactions.get(key)!.push({
|
||||||
|
account: evt.sender,
|
||||||
|
event_id: evt.id,
|
||||||
|
key,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adapt receipts to be event-indexed
|
||||||
|
this.receiptsEventsMap.clear();
|
||||||
|
for (const receipt of [...receiptsUsersMap.values()]) {
|
||||||
|
if (!this.receiptsEventsMap.has(receipt.event_id))
|
||||||
|
this.receiptsEventsMap.set(receipt.event_id, [receipt]);
|
||||||
|
else this.receiptsEventsMap.get(receipt.event_id)!.push(receipt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Alert, Box, Button, CircularProgress } from "@mui/material";
|
import { Alert, Box, Button, CircularProgress } from "@mui/material";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import React from "react";
|
||||||
|
|
||||||
const State = {
|
const State = {
|
||||||
Loading: 0,
|
Loading: 0,
|
||||||
@@ -10,16 +10,14 @@ const State = {
|
|||||||
type State = keyof typeof State;
|
type State = keyof typeof State;
|
||||||
|
|
||||||
export function AsyncWidget(p: {
|
export function AsyncWidget(p: {
|
||||||
loadKey: any;
|
loadKey: unknown;
|
||||||
load: () => Promise<void>;
|
load: () => Promise<void>;
|
||||||
errMsg: string;
|
errMsg: string;
|
||||||
build: () => React.ReactElement;
|
build: () => React.ReactElement;
|
||||||
ready?: boolean;
|
ready?: boolean;
|
||||||
errAdditionalElement?: () => React.ReactElement;
|
errAdditionalElement?: () => React.ReactElement;
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const [state, setState] = useState<number>(State.Loading);
|
const [state, setState] = React.useState<number>(State.Loading);
|
||||||
|
|
||||||
const counter = useRef<any>(null);
|
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -32,12 +30,10 @@ export function AsyncWidget(p: {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (counter.current === p.loadKey) return;
|
|
||||||
counter.current = p.loadKey;
|
|
||||||
|
|
||||||
load();
|
load();
|
||||||
});
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [p.loadKey]);
|
||||||
|
|
||||||
if (state === State.Error)
|
if (state === State.Error)
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
export function BaseAuthenticatedPage(): React.ReactElement {
|
|
||||||
return <p>todo authenticated</p>;
|
|
||||||
}
|
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
matrixgw_frontend/src/widgets/EmojiIcon.tsx
Normal file
31
matrixgw_frontend/src/widgets/EmojiIcon.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Emoji, EmojiStyle } from "emoji-picker-react";
|
||||||
|
|
||||||
|
function emojiUnicode(emoji: string): string {
|
||||||
|
let comp;
|
||||||
|
if (emoji.length === 1) {
|
||||||
|
comp = emoji.charCodeAt(0);
|
||||||
|
}
|
||||||
|
comp =
|
||||||
|
(emoji.charCodeAt(0) - 0xd800) * 0x400 +
|
||||||
|
(emoji.charCodeAt(1) - 0xdc00) +
|
||||||
|
0x10000;
|
||||||
|
if (comp < 0) {
|
||||||
|
comp = emoji.charCodeAt(0);
|
||||||
|
}
|
||||||
|
const s = comp.toString(16);
|
||||||
|
return s.includes("f") ? s : `${s}-fe0f`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmojiIcon(p: {
|
||||||
|
emojiKey: string;
|
||||||
|
size?: number;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const unified = emojiUnicode(p.emojiKey);
|
||||||
|
return (
|
||||||
|
<Emoji
|
||||||
|
unified={unified ?? ""}
|
||||||
|
emojiStyle={EmojiStyle.NATIVE}
|
||||||
|
size={p.size ?? 18}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
matrixgw_frontend/src/widgets/MatrixGWRouteContainer.tsx
Normal file
37
matrixgw_frontend/src/widgets/MatrixGWRouteContainer.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Typography } from "@mui/material";
|
||||||
|
import React, { type PropsWithChildren } from "react";
|
||||||
|
|
||||||
|
export function MatrixGWRouteContainer(
|
||||||
|
p: {
|
||||||
|
label: string | React.ReactElement;
|
||||||
|
actions?: React.ReactElement;
|
||||||
|
} & PropsWithChildren
|
||||||
|
): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
margin: "50px",
|
||||||
|
flexGrow: 1,
|
||||||
|
flexShrink: 0,
|
||||||
|
flexBasis: 0,
|
||||||
|
minWidth: 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: "20px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h4">{p.label}</Typography>
|
||||||
|
{p.actions ?? <></>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{p.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
matrixgw_frontend/src/widgets/NotLinkedAccountMessage.tsx
Normal file
26
matrixgw_frontend/src/widgets/NotLinkedAccountMessage.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
|
||||||
|
import { Button } from "@mui/material";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
|
||||||
|
export function NotLinkedAccountMessage(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: "50px" }}>
|
||||||
|
Your Matrix account is not linked yet!
|
||||||
|
</div>
|
||||||
|
<Link to={"/matrix_link"}>
|
||||||
|
<Button variant="outlined" startIcon={<ArrowForwardIcon />}>
|
||||||
|
Go to Matrix Link settings
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
matrixgw_frontend/src/widgets/TimeWidget.tsx
Normal file
31
matrixgw_frontend/src/widgets/TimeWidget.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Tooltip } from "@mui/material";
|
||||||
|
import {
|
||||||
|
formatDateTime,
|
||||||
|
formatDate,
|
||||||
|
timeDiff,
|
||||||
|
timeDiffFromNow,
|
||||||
|
} from "../utils/DateUtils";
|
||||||
|
|
||||||
|
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,10 +1,10 @@
|
|||||||
import { mdiMessageTextFast } from "@mdi/js";
|
|
||||||
import Icon from "@mdi/react";
|
|
||||||
import { Typography } from "@mui/material";
|
import { Typography } from "@mui/material";
|
||||||
import MuiCard from "@mui/material/Card";
|
import MuiCard from "@mui/material/Card";
|
||||||
import Stack from "@mui/material/Stack";
|
import Stack from "@mui/material/Stack";
|
||||||
import { styled } from "@mui/material/styles";
|
import { styled } from "@mui/material/styles";
|
||||||
import { Outlet } from "react-router";
|
import { Outlet } from "react-router";
|
||||||
|
import { AppIcon } from "../../icons/AppIcon";
|
||||||
|
import mdiMessageTextFast from "../../icons/message-text-fast.svg";
|
||||||
|
|
||||||
const Card = styled(MuiCard)(({ theme }) => ({
|
const Card = styled(MuiCard)(({ theme }) => ({
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -57,12 +57,7 @@ export function BaseLoginPage(): React.ReactElement {
|
|||||||
variant="h4"
|
variant="h4"
|
||||||
sx={{ width: "100%", fontSize: "clamp(2rem, 10vw, 2.15rem)" }}
|
sx={{ width: "100%", fontSize: "clamp(2rem, 10vw, 2.15rem)" }}
|
||||||
>
|
>
|
||||||
<Icon
|
<AppIcon src={mdiMessageTextFast} size={"2em"} /> MatrixGW
|
||||||
path={mdiMessageTextFast}
|
|
||||||
size={"1em"}
|
|
||||||
style={{ display: "inline-table" }}
|
|
||||||
/>{" "}
|
|
||||||
MatrixGW
|
|
||||||
</Typography>
|
</Typography>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { Button } from "@mui/material";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
|
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";
|
||||||
|
|
||||||
|
interface UserInfoContext {
|
||||||
|
info: UserInfo;
|
||||||
|
reloadUserInfo: () => void;
|
||||||
|
signOut: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
const signOut = () => {
|
||||||
|
AuthApi.SignOut();
|
||||||
|
navigate("/");
|
||||||
|
auth.setSignedIn(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const [isDesktopNavigationExpanded, setIsDesktopNavigationExpanded] =
|
||||||
|
React.useState(false);
|
||||||
|
const [isMobileNavigationExpanded, setIsMobileNavigationExpanded] =
|
||||||
|
React.useState(false);
|
||||||
|
|
||||||
|
const isOverMdViewport = useMediaQuery(theme.breakpoints.up("md"));
|
||||||
|
|
||||||
|
const isNavigationExpanded = isOverMdViewport
|
||||||
|
? isDesktopNavigationExpanded
|
||||||
|
: isMobileNavigationExpanded;
|
||||||
|
|
||||||
|
const setIsNavigationExpanded = React.useCallback(
|
||||||
|
(newExpanded: boolean) => {
|
||||||
|
if (isOverMdViewport) {
|
||||||
|
setIsDesktopNavigationExpanded(newExpanded);
|
||||||
|
} else {
|
||||||
|
setIsMobileNavigationExpanded(newExpanded);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
isOverMdViewport,
|
||||||
|
setIsDesktopNavigationExpanded,
|
||||||
|
setIsMobileNavigationExpanded,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleToggleHeaderMenu = React.useCallback(
|
||||||
|
(isExpanded: boolean) => {
|
||||||
|
setIsNavigationExpanded(isExpanded);
|
||||||
|
},
|
||||||
|
[setIsNavigationExpanded]
|
||||||
|
);
|
||||||
|
|
||||||
|
const layoutRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AsyncWidget
|
||||||
|
loadKey="1"
|
||||||
|
load={loadUserInfo}
|
||||||
|
errMsg="Failed to load user information!"
|
||||||
|
errAdditionalElement={() => (
|
||||||
|
<>
|
||||||
|
<Button onClick={signOut}>Sign out</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
build={() => (
|
||||||
|
<UserInfoContextK
|
||||||
|
value={{
|
||||||
|
info: userInfo!,
|
||||||
|
reloadUserInfo,
|
||||||
|
signOut,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DashboardHeader
|
||||||
|
menuOpen={isNavigationExpanded}
|
||||||
|
onToggleMenu={handleToggleHeaderMenu}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
ref={layoutRef}
|
||||||
|
sx={{
|
||||||
|
position: "relative",
|
||||||
|
display: "flex",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DashboardSidebar
|
||||||
|
expanded={isNavigationExpanded}
|
||||||
|
setExpanded={setIsNavigationExpanded}
|
||||||
|
container={layoutRef?.current ?? undefined}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="main"
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
flex: 1,
|
||||||
|
overflow: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</UserInfoContextK>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUserInfo(): UserInfoContext {
|
||||||
|
return React.use(UserInfoContextK)!;
|
||||||
|
}
|
||||||
160
matrixgw_frontend/src/widgets/dashboard/DashboardHeader.tsx
Normal file
160
matrixgw_frontend/src/widgets/dashboard/DashboardHeader.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import LogoutIcon from "@mui/icons-material/Logout";
|
||||||
|
import MenuIcon from "@mui/icons-material/Menu";
|
||||||
|
import MenuOpenIcon from "@mui/icons-material/MenuOpen";
|
||||||
|
import { Avatar } from "@mui/material";
|
||||||
|
import MuiAppBar from "@mui/material/AppBar";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import { styled } from "@mui/material/styles";
|
||||||
|
import Toolbar from "@mui/material/Toolbar";
|
||||||
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import * as React from "react";
|
||||||
|
import { AppIcon } from "../../icons/AppIcon";
|
||||||
|
import mdiMessageTextFast from "../../icons/message-text-fast.svg";
|
||||||
|
import { RouterLink } from "../RouterLink";
|
||||||
|
import { useUserInfo } from "./BaseAuthenticatedPage";
|
||||||
|
import ThemeSwitcher from "./ThemeSwitcher";
|
||||||
|
|
||||||
|
const AppBar = styled(MuiAppBar)(({ theme }) => ({
|
||||||
|
borderWidth: 0,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: (theme.vars ?? theme).palette.divider,
|
||||||
|
boxShadow: "none",
|
||||||
|
zIndex: theme.zIndex.drawer + 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const LogoContainer = styled("div")({
|
||||||
|
position: "relative",
|
||||||
|
height: 40,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
"& img": {
|
||||||
|
maxHeight: 40,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface DashboardHeaderProps {
|
||||||
|
menuOpen: boolean;
|
||||||
|
onToggleMenu: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardHeader({
|
||||||
|
menuOpen,
|
||||||
|
onToggleMenu,
|
||||||
|
}: DashboardHeaderProps) {
|
||||||
|
const user = useUserInfo();
|
||||||
|
|
||||||
|
const handleMenuOpen = React.useCallback(() => {
|
||||||
|
onToggleMenu(!menuOpen);
|
||||||
|
}, [menuOpen, onToggleMenu]);
|
||||||
|
|
||||||
|
const getMenuIcon = React.useCallback(
|
||||||
|
(isExpanded: boolean) => {
|
||||||
|
const expandMenuActionText = "Expand";
|
||||||
|
const collapseMenuActionText = "Collapse";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
title={`${
|
||||||
|
isExpanded ? collapseMenuActionText : expandMenuActionText
|
||||||
|
} menu`}
|
||||||
|
enterDelay={200}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label={`${
|
||||||
|
isExpanded ? collapseMenuActionText : expandMenuActionText
|
||||||
|
} navigation menu`}
|
||||||
|
onClick={handleMenuOpen}
|
||||||
|
>
|
||||||
|
{isExpanded ? <MenuOpenIcon /> : <MenuIcon />}
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[handleMenuOpen]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBar
|
||||||
|
color="inherit"
|
||||||
|
position="static"
|
||||||
|
sx={{ displayPrint: "none", overflow: "hidden" }}
|
||||||
|
>
|
||||||
|
<Toolbar sx={{ backgroundColor: "inherit", mx: { xs: -0.75, sm: -1 } }}>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
justifyContent="space-between"
|
||||||
|
alignItems="center"
|
||||||
|
sx={{
|
||||||
|
flexWrap: "wrap",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" alignItems="center">
|
||||||
|
<Box sx={{ mr: 3 }}>{getMenuIcon(menuOpen)}</Box>
|
||||||
|
<RouterLink to="/">
|
||||||
|
<Stack direction="row" alignItems="center">
|
||||||
|
<LogoContainer>
|
||||||
|
<AppIcon src={mdiMessageTextFast} size="2em" />
|
||||||
|
</LogoContainer>
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
sx={{
|
||||||
|
fontWeight: "700",
|
||||||
|
ml: 1,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
lineHeight: 1,
|
||||||
|
display: { xs: "none", sm: "block" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
MatrixGW
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</RouterLink>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* User avatar */}
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
gap: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
borderTop: "1px solid",
|
||||||
|
borderColor: "divider",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
sizes="small"
|
||||||
|
alt={user.info.name}
|
||||||
|
sx={{ width: 36, height: 36 }}
|
||||||
|
/>
|
||||||
|
<Box sx={{ mr: "auto", display: { xs: "none", md: "block" } }}>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{ fontWeight: 500, lineHeight: "16px" }}
|
||||||
|
>
|
||||||
|
{user.info.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||||
|
{user.info.email}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<ThemeSwitcher />
|
||||||
|
<Tooltip title="Sign out">
|
||||||
|
<IconButton size="small" onClick={user.signOut}>
|
||||||
|
<LogoutIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
);
|
||||||
|
}
|
||||||
207
matrixgw_frontend/src/widgets/dashboard/DashboardSidebar.tsx
Normal file
207
matrixgw_frontend/src/widgets/dashboard/DashboardSidebar.tsx
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import BugReportIcon from "@mui/icons-material/BugReport";
|
||||||
|
import ForumIcon from "@mui/icons-material/Forum";
|
||||||
|
import KeyIcon from "@mui/icons-material/Key";
|
||||||
|
import LinkIcon from "@mui/icons-material/Link";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Drawer from "@mui/material/Drawer";
|
||||||
|
import List from "@mui/material/List";
|
||||||
|
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";
|
||||||
|
import { DRAWER_WIDTH, MINI_DRAWER_WIDTH } from "./constants";
|
||||||
|
import {
|
||||||
|
getDrawerSxTransitionMixin,
|
||||||
|
getDrawerWidthTransitionMixin,
|
||||||
|
} from "./mixins";
|
||||||
|
|
||||||
|
export interface DashboardSidebarProps {
|
||||||
|
expanded?: boolean;
|
||||||
|
setExpanded: (expanded: boolean) => void;
|
||||||
|
disableCollapsibleSidebar?: boolean;
|
||||||
|
container?: Element;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardSidebar({
|
||||||
|
expanded = true,
|
||||||
|
setExpanded,
|
||||||
|
container,
|
||||||
|
}: DashboardSidebarProps) {
|
||||||
|
const theme = useTheme();
|
||||||
|
const user = useUserInfo();
|
||||||
|
|
||||||
|
const isOverSmViewport = useMediaQuery(theme.breakpoints.up("sm"));
|
||||||
|
const isOverMdViewport = useMediaQuery(theme.breakpoints.up("md"));
|
||||||
|
|
||||||
|
const [isFullyExpanded, setIsFullyExpanded] = React.useState(expanded);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (expanded) {
|
||||||
|
const drawerWidthTransitionTimeout = setTimeout(() => {
|
||||||
|
setIsFullyExpanded(true);
|
||||||
|
}, theme.transitions.duration.enteringScreen);
|
||||||
|
|
||||||
|
return () => clearTimeout(drawerWidthTransitionTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsFullyExpanded(false);
|
||||||
|
|
||||||
|
return () => {};
|
||||||
|
}, [expanded, theme.transitions.duration.enteringScreen]);
|
||||||
|
|
||||||
|
const handleSetSidebarExpanded = React.useCallback(
|
||||||
|
(newExpanded: boolean) => () => {
|
||||||
|
setExpanded(newExpanded);
|
||||||
|
},
|
||||||
|
[setExpanded]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePageItemClick = React.useCallback(() => {
|
||||||
|
if (!isOverSmViewport) {
|
||||||
|
setExpanded(false);
|
||||||
|
}
|
||||||
|
}, [setExpanded, isOverSmViewport]);
|
||||||
|
|
||||||
|
const hasDrawerTransitions = isOverSmViewport && isOverMdViewport;
|
||||||
|
|
||||||
|
const getDrawerContent = React.useCallback(
|
||||||
|
(viewport: "phone" | "desktop") => (
|
||||||
|
<React.Fragment>
|
||||||
|
<Box
|
||||||
|
component="nav"
|
||||||
|
aria-label={`${viewport.charAt(0).toUpperCase()}${viewport.slice(1)}`}
|
||||||
|
sx={{
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
overflow: "auto",
|
||||||
|
scrollbarGutter: !expanded ? "stable" : "auto",
|
||||||
|
overflowX: "hidden",
|
||||||
|
pt: expanded ? 0 : 2,
|
||||||
|
paddingTop: 0,
|
||||||
|
...(hasDrawerTransitions
|
||||||
|
? getDrawerSxTransitionMixin(isFullyExpanded, "padding")
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<List
|
||||||
|
dense
|
||||||
|
sx={{
|
||||||
|
padding: !expanded ? 0 : 0.5,
|
||||||
|
mb: 4,
|
||||||
|
width: !expanded ? MINI_DRAWER_WIDTH : "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DashboardSidebarPageItem
|
||||||
|
disabled={!user.info.matrix_account_connected}
|
||||||
|
title="Messages"
|
||||||
|
icon={<ForumIcon style={{ height: "1em", width: "1em" }} />}
|
||||||
|
href="/"
|
||||||
|
mini={viewport === "desktop"}
|
||||||
|
/>
|
||||||
|
<DashboardSidebarDividerItem />
|
||||||
|
<DashboardSidebarPageItem
|
||||||
|
title="Matrix link"
|
||||||
|
icon={<LinkIcon style={{ height: "1em", width: "1em" }} />}
|
||||||
|
href="/matrix_link"
|
||||||
|
mini={viewport === "desktop"}
|
||||||
|
/>
|
||||||
|
<DashboardSidebarPageItem
|
||||||
|
title="API tokens"
|
||||||
|
icon={<KeyIcon style={{ height: "1em", width: "1em" }} />}
|
||||||
|
href="/tokens"
|
||||||
|
mini={viewport === "desktop"}
|
||||||
|
/>
|
||||||
|
<DashboardSidebarPageItem
|
||||||
|
disabled={!user.info.matrix_account_connected}
|
||||||
|
title="WS Debug"
|
||||||
|
icon={<BugReportIcon style={{ height: "1em", width: "1em" }} />}
|
||||||
|
href="/wsdebug"
|
||||||
|
mini={viewport === "desktop"}
|
||||||
|
/>
|
||||||
|
</List>
|
||||||
|
</Box>
|
||||||
|
</React.Fragment>
|
||||||
|
),
|
||||||
|
[
|
||||||
|
expanded,
|
||||||
|
hasDrawerTransitions,
|
||||||
|
isFullyExpanded,
|
||||||
|
user.info.matrix_account_connected,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getDrawerSharedSx = React.useCallback(
|
||||||
|
(isTemporary: boolean, desktop?: boolean) => {
|
||||||
|
const drawerWidth = desktop
|
||||||
|
? expanded
|
||||||
|
? MINI_DRAWER_WIDTH
|
||||||
|
: 0
|
||||||
|
: !expanded
|
||||||
|
? MINI_DRAWER_WIDTH
|
||||||
|
: DRAWER_WIDTH;
|
||||||
|
|
||||||
|
return {
|
||||||
|
displayPrint: "none",
|
||||||
|
width: drawerWidth,
|
||||||
|
flexShrink: 0,
|
||||||
|
...getDrawerWidthTransitionMixin(expanded),
|
||||||
|
...(isTemporary ? { position: "absolute" } : {}),
|
||||||
|
[`& .MuiDrawer-paper`]: {
|
||||||
|
position: "absolute",
|
||||||
|
width: drawerWidth,
|
||||||
|
boxSizing: "border-box",
|
||||||
|
backgroundImage: "none",
|
||||||
|
...getDrawerWidthTransitionMixin(expanded),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[expanded]
|
||||||
|
);
|
||||||
|
|
||||||
|
const sidebarContextValue = React.useMemo(() => {
|
||||||
|
return {
|
||||||
|
onPageItemClick: handlePageItemClick,
|
||||||
|
fullyExpanded: isFullyExpanded,
|
||||||
|
hasDrawerTransitions,
|
||||||
|
};
|
||||||
|
}, [handlePageItemClick, isFullyExpanded, hasDrawerTransitions]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardSidebarContext.Provider value={sidebarContextValue}>
|
||||||
|
<Drawer
|
||||||
|
container={container}
|
||||||
|
variant="temporary"
|
||||||
|
open={expanded}
|
||||||
|
onClose={handleSetSidebarExpanded(false)}
|
||||||
|
ModalProps={{
|
||||||
|
keepMounted: true, // Better open performance on mobile.
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
display: {
|
||||||
|
xs: "block",
|
||||||
|
sm: "none",
|
||||||
|
md: "none",
|
||||||
|
},
|
||||||
|
...getDrawerSharedSx(true),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getDrawerContent("phone")}
|
||||||
|
</Drawer>
|
||||||
|
<Drawer
|
||||||
|
variant="permanent"
|
||||||
|
sx={{
|
||||||
|
display: { xs: "none", sm: "block", md: "block" },
|
||||||
|
...getDrawerSharedSx(false, true),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getDrawerContent("desktop")}
|
||||||
|
</Drawer>
|
||||||
|
</DashboardSidebarContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
const DashboardSidebarContext = React.createContext<{
|
||||||
|
onPageItemClick: () => void;
|
||||||
|
fullyExpanded: boolean;
|
||||||
|
hasDrawerTransitions: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
export default DashboardSidebarContext;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import Divider from "@mui/material/Divider";
|
||||||
|
import type {} from "@mui/material/themeCssVarsAugmentation";
|
||||||
|
import DashboardSidebarContext from "./DashboardSidebarContext";
|
||||||
|
import { getDrawerSxTransitionMixin } from "./mixins";
|
||||||
|
|
||||||
|
export default function DashboardSidebarDividerItem() {
|
||||||
|
const sidebarContext = React.useContext(DashboardSidebarContext);
|
||||||
|
if (!sidebarContext) {
|
||||||
|
throw new Error("Sidebar context was used without a provider.");
|
||||||
|
}
|
||||||
|
const { fullyExpanded = true, hasDrawerTransitions } = sidebarContext;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<Divider
|
||||||
|
sx={{
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
my: 1,
|
||||||
|
mx: -0.5,
|
||||||
|
...(hasDrawerTransitions
|
||||||
|
? getDrawerSxTransitionMixin(fullyExpanded, "margin")
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import Avatar from "@mui/material/Avatar";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import ListItem from "@mui/material/ListItem";
|
||||||
|
import ListItemButton from "@mui/material/ListItemButton";
|
||||||
|
import ListItemIcon from "@mui/material/ListItemIcon";
|
||||||
|
import ListItemText from "@mui/material/ListItemText";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import type {} from "@mui/material/themeCssVarsAugmentation";
|
||||||
|
import * as React from "react";
|
||||||
|
import { Link, matchPath, useLocation } from "react-router";
|
||||||
|
import DashboardSidebarContext from "./DashboardSidebarContext";
|
||||||
|
import { MINI_DRAWER_WIDTH } from "./constants";
|
||||||
|
|
||||||
|
export interface DashboardSidebarPageItemProps {
|
||||||
|
title: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
href: string;
|
||||||
|
action?: React.ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
|
mini?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardSidebarPageItem({
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
href,
|
||||||
|
action,
|
||||||
|
disabled = false,
|
||||||
|
mini = false,
|
||||||
|
}: DashboardSidebarPageItemProps) {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
|
const sidebarContext = React.useContext(DashboardSidebarContext);
|
||||||
|
if (!sidebarContext) {
|
||||||
|
throw new Error("Sidebar context was used without a provider.");
|
||||||
|
}
|
||||||
|
const { onPageItemClick, fullyExpanded = true } = sidebarContext;
|
||||||
|
|
||||||
|
const hasExternalHref = href
|
||||||
|
? href.startsWith("http://") || href.startsWith("https://")
|
||||||
|
: false;
|
||||||
|
|
||||||
|
const LinkComponent = hasExternalHref ? "a" : Link;
|
||||||
|
|
||||||
|
const selected = !!matchPath(href, pathname);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<ListItem disablePadding style={{ padding: "5px" }}>
|
||||||
|
<ListItemButton
|
||||||
|
selected={selected}
|
||||||
|
disabled={disabled}
|
||||||
|
sx={{
|
||||||
|
height: mini ? 50 : "auto",
|
||||||
|
}}
|
||||||
|
{...{
|
||||||
|
LinkComponent,
|
||||||
|
...(hasExternalHref
|
||||||
|
? {
|
||||||
|
target: "_blank",
|
||||||
|
rel: "noopener noreferrer",
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
to: href,
|
||||||
|
onClick: onPageItemClick,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon || mini ? (
|
||||||
|
<Box
|
||||||
|
sx={
|
||||||
|
mini
|
||||||
|
? {
|
||||||
|
position: "absolute",
|
||||||
|
left: "50%",
|
||||||
|
top: "calc(50% - 6px)",
|
||||||
|
transform: "translate(-50%, -50%)",
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ListItemIcon
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: mini ? "center" : "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon ?? null}
|
||||||
|
{!icon && mini ? (
|
||||||
|
<Avatar
|
||||||
|
sx={{
|
||||||
|
fontSize: 10,
|
||||||
|
height: 16,
|
||||||
|
width: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title
|
||||||
|
.split(" ")
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((titleWord) => titleWord.charAt(0).toUpperCase())}
|
||||||
|
</Avatar>
|
||||||
|
) : null}
|
||||||
|
</ListItemIcon>
|
||||||
|
{mini ? (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: -18,
|
||||||
|
left: "50%",
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 500,
|
||||||
|
textAlign: "center",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
maxWidth: MINI_DRAWER_WIDTH - 28,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
{!mini ? (
|
||||||
|
<ListItemText
|
||||||
|
primary={title}
|
||||||
|
sx={{
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{action && !mini && fullyExpanded ? action : null}
|
||||||
|
</ListItemButton>
|
||||||
|
</ListItem>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import Divider from "@mui/material/Divider";
|
||||||
|
import type {} from "@mui/material/themeCssVarsAugmentation";
|
||||||
|
import DashboardSidebarContext from "./DashboardSidebarContext";
|
||||||
|
import { getDrawerSxTransitionMixin } from "./mixins";
|
||||||
|
|
||||||
|
export default function DashboardSidebarDividerItem() {
|
||||||
|
const sidebarContext = React.useContext(DashboardSidebarContext);
|
||||||
|
if (!sidebarContext) {
|
||||||
|
throw new Error("Sidebar context was used without a provider.");
|
||||||
|
}
|
||||||
|
const { fullyExpanded = true, hasDrawerTransitions } = sidebarContext;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<Divider
|
||||||
|
sx={{
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
my: 1,
|
||||||
|
mx: -0.5,
|
||||||
|
...(hasDrawerTransitions
|
||||||
|
? getDrawerSxTransitionMixin(fullyExpanded, "margin")
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
matrixgw_frontend/src/widgets/dashboard/ThemeSwitcher.tsx
Normal file
64
matrixgw_frontend/src/widgets/dashboard/ThemeSwitcher.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { useTheme, useColorScheme } from "@mui/material/styles";
|
||||||
|
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
|
import DarkModeIcon from "@mui/icons-material/DarkMode";
|
||||||
|
import LightModeIcon from "@mui/icons-material/LightMode";
|
||||||
|
import type {} from "@mui/material/themeCssVarsAugmentation";
|
||||||
|
|
||||||
|
export function useActualColorMode(): {
|
||||||
|
mode: "light" | "dark";
|
||||||
|
setMode: (mode: "light" | "dark") => void;
|
||||||
|
} {
|
||||||
|
const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
|
||||||
|
const preferredMode = prefersDarkMode ? "dark" : "light";
|
||||||
|
|
||||||
|
const { mode, setMode } = useColorScheme();
|
||||||
|
|
||||||
|
const paletteMode = !mode || mode === "system" ? preferredMode : mode;
|
||||||
|
|
||||||
|
return { mode: paletteMode, setMode };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ThemeSwitcher() {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
const { mode, setMode } = useActualColorMode();
|
||||||
|
|
||||||
|
const toggleMode = React.useCallback(() => {
|
||||||
|
setMode(mode === "dark" ? "light" : "dark");
|
||||||
|
}, [mode, setMode]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
title={`${mode === "dark" ? "Light" : "Dark"} mode`}
|
||||||
|
enterDelay={1000}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label={`Switch to ${mode === "dark" ? "light" : "dark"} mode`}
|
||||||
|
onClick={toggleMode}
|
||||||
|
>
|
||||||
|
<LightModeIcon
|
||||||
|
sx={{
|
||||||
|
display: "inline",
|
||||||
|
[theme.getColorSchemeSelector("dark")]: {
|
||||||
|
display: "none",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<DarkModeIcon
|
||||||
|
sx={{
|
||||||
|
display: "none",
|
||||||
|
[theme.getColorSchemeSelector("dark")]: {
|
||||||
|
display: "inline",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
matrixgw_frontend/src/widgets/dashboard/constants.ts
Normal file
2
matrixgw_frontend/src/widgets/dashboard/constants.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export const DRAWER_WIDTH = 240; // px
|
||||||
|
export const MINI_DRAWER_WIDTH = 90; // px
|
||||||
23
matrixgw_frontend/src/widgets/dashboard/mixins.ts
Normal file
23
matrixgw_frontend/src/widgets/dashboard/mixins.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { type Theme } from "@mui/material/styles";
|
||||||
|
|
||||||
|
export function getDrawerSxTransitionMixin(
|
||||||
|
isExpanded: boolean,
|
||||||
|
property: string
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
transition: (theme: Theme) =>
|
||||||
|
theme.transitions.create(property, {
|
||||||
|
easing: theme.transitions.easing.sharp,
|
||||||
|
duration: isExpanded
|
||||||
|
? theme.transitions.duration.enteringScreen
|
||||||
|
: theme.transitions.duration.leavingScreen,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDrawerWidthTransitionMixin(isExpanded: boolean) {
|
||||||
|
return {
|
||||||
|
...getDrawerSxTransitionMixin(isExpanded, "width"),
|
||||||
|
overflowX: "hidden",
|
||||||
|
};
|
||||||
|
}
|
||||||
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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
matrixgw_frontend/src/widgets/messages/AccountIcon.tsx
Normal file
19
matrixgw_frontend/src/widgets/messages/AccountIcon.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Avatar } from "@mui/material";
|
||||||
|
import type { UserProfile } from "../../api/matrix/MatrixApiProfile";
|
||||||
|
import { MatrixApiMedia } from "../../api/matrix/MatrixApiMedia";
|
||||||
|
|
||||||
|
export function AccountIcon(p: {
|
||||||
|
user: UserProfile;
|
||||||
|
size?: number;
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<Avatar
|
||||||
|
src={
|
||||||
|
p.user.avatar ? MatrixApiMedia.MediaURL(p.user.avatar, true) : undefined
|
||||||
|
}
|
||||||
|
sx={{ width: p.size, height: p.size }}
|
||||||
|
>
|
||||||
|
{p.user.display_name?.slice(0, 1)}
|
||||||
|
</Avatar>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user