141 Commits

Author SHA1 Message Date
8a4570a044 Fix Drone configuration
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-12-04 17:23:06 +01:00
e51fc6b4bb Fix ESLint issues
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is failing
2025-12-04 16:32:06 +01:00
0f68d59798 Fix spaces support in UI
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-04 15:35:16 +01:00
5ad23005be Fix time alignment
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-04 15:03:38 +01:00
4e096a1d49 Can get spaces hierarchy
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-04 09:18:32 +01:00
ac2a361b77 Can filter rooms list
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-04 08:53:11 +01:00
24f8d67020 Fix bug in screen 2025-12-04 08:36:48 +01:00
5bcee2ea9d Reduce the number of loaded messages per conversations
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-03 20:39:26 +01:00
48d9444dde Add a button to manually load older messages
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-03 20:18:11 +01:00
bcdfe87107 Add reply support through WebSocket
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-03 19:22:53 +01:00
5088699c15 Add replies support
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-03 19:20:17 +01:00
854b474970 Fix lock file
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-03 16:28:21 +01:00
336aea463b Merge branch 'migrate-to-matrix-sdk'
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-03 16:22:49 +01:00
fe9c692e12 Fix alignment inside WSDebugRoute
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is passing
2025-12-03 16:18:03 +01:00
b47ec37a76 Remove unused imports in login route
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is passing
2025-12-03 16:12:00 +01:00
996534c62b Fix emoji size
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2025-12-03 16:10:56 +01:00
3ba6543cb4 Remove @mdi/js as a dependency
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2025-12-03 16:07:34 +01:00
f087b27b53 Updated frontend dependencies
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is passing
2025-12-03 15:13:01 +01:00
dfcf764a9b Updated backend dependencies 2025-12-03 15:00:58 +01:00
fb35fca56e Fix build issues
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is passing
2025-12-03 14:53:06 +01:00
f6568cf059 Fix ESLint issues
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2025-12-03 14:38:58 +01:00
bbf558bbf9 WIP ESLint fixes
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2025-12-03 11:16:14 +01:00
1090a59aaf Quick ESLint issues fix
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2025-12-03 09:48:03 +01:00
30518f3ca3 Fix Web build
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2025-12-03 09:46:51 +01:00
e215fe6484 Add Renovate config
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-12-03 09:45:46 +01:00
6392c0a2c7 Add production Makefile 2025-12-03 09:43:04 +01:00
4110f4d063 Can load older messages in conversations 2025-12-03 09:11:02 +01:00
1a5a021711 Can filter to show only unread rooms 2025-12-03 08:53:44 +01:00
8b299bcf8f Merge pull request 'Update Rust crate serde_json to 1.0.145' (#94) from renovate/serde_json-1.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-03 00:15:17 +00:00
ab136ef6d0 Display a message when the conversation is empty 2025-12-02 13:59:14 +01:00
1fa98cf6e3 Update Rust crate serde_json to 1.0.145
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-12-02 00:14:39 +00:00
118b73fce9 Improve receipts spacing 2025-12-01 19:32:50 +01:00
95fb095205 Switch to native emojies 2025-12-01 19:29:18 +01:00
3274d07635 Do not display spaces bar if it is useless 2025-12-01 19:06:58 +01:00
6d78930b89 Add link on unlinked account message 2025-12-01 19:05:37 +01:00
7356a66e4a Handle read receipts on web ui 2025-12-01 18:30:22 +01:00
30e63bfdb4 Handle typing events 2025-12-01 17:23:15 +01:00
e80d54d0e7 Add auto-release configuration
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-12-01 11:27:13 +01:00
b91b61f4f0 Downgrade ruma
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-01 11:24:36 +01:00
32354f79ea Add typing event definition 2025-12-01 11:18:58 +01:00
077c64be28 Forward typing event in WebSocket 2025-12-01 11:17:02 +01:00
dac20f60e0 Can get read receipts 2025-12-01 11:09:14 +01:00
9359dc5be0 Send read receipts 2025-12-01 10:42:19 +01:00
849aef9343 Update unread messages count only if room is not muted 2025-12-01 10:30:54 +01:00
196671d0fb Remove unread marker when receiving proper read receipt 2025-12-01 10:25:14 +01:00
b93100413c Propagate read receipt events 2025-12-01 10:12:11 +01:00
57797e933a Can check if rooms are muted 2025-12-01 09:12:58 +01:00
7acb0cbafa Display WS state in favicon 2025-12-01 08:44:30 +01:00
64985bb39e Display application icon 2025-12-01 08:36:29 +01:00
1f22d5c41b Refactor rooms management 2025-11-28 18:41:43 +01:00
a656c077bc Follow unread messages 2025-11-28 18:06:40 +01:00
d10c4d1a1c Minor appearance improvement 2025-11-28 17:39:27 +01:00
62966473f0 Add support for more file formats 2025-11-28 17:37:30 +01:00
c360432911 Add support for unencrypted media 2025-11-28 17:22:58 +01:00
123e069d18 Add multi line messages supports 2025-11-28 17:15:33 +01:00
4b30d67706 Basic WS sync 2025-11-28 17:00:42 +01:00
799341f77c Display the list of people who reacted 2025-11-28 14:42:02 +01:00
6c11979ef2 Display reactions below messages 2025-11-28 14:37:17 +01:00
9f0bc3303c Add quick reactions 2025-11-28 12:05:29 +01:00
f5b16b6ce4 Can send reaction from picker 2025-11-28 11:41:19 +01:00
756780513b Fix dialog name 2025-11-28 10:41:30 +01:00
93487a5325 Can edit message content 2025-11-28 10:41:01 +01:00
3d27279a16 Can delete events from WebUI 2025-11-28 10:22:44 +01:00
94ce9c3c95 Add buttons bar 2025-11-28 09:55:21 +01:00
9f83a6fb66 Can send text messages in conversations 2025-11-27 19:13:16 +01:00
bda47a2770 Improve messages appearance 2025-11-27 18:55:30 +01:00
f0e8c799ff Merge pull request 'Update Rust crate actix-web to 4.12.1' (#119) from renovate/actix-web-4.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-27 00:16:23 +00:00
b4e7cb8718 Update Rust crate actix-web to 4.12.1
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-11-27 00:16:15 +00:00
b7378aa4dc Can retrieve room media 2025-11-25 14:54:02 +01:00
2adbf146d0 Start to display messages list 2025-11-25 12:17:48 +01:00
5eab7c3e4f Process events list client side 2025-11-25 09:48:49 +01:00
a7bfd713c3 Ready to implement room widget 2025-11-24 17:59:12 +01:00
4be661d999 Fix appearance of unread conversations 2025-11-24 17:55:26 +01:00
1f4e374e66 Display rooms list 2025-11-24 17:50:31 +01:00
cce9b3de5d Hide menu by default on desktop 2025-11-24 16:36:36 +01:00
820b095be0 Display the list of spaces 2025-11-24 16:05:01 +01:00
0a37688116 Can react to event 2025-11-24 13:40:14 +01:00
4d72644a31 Can edit message 2025-11-24 13:18:23 +01:00
0a395b0d26 Can redact message 2025-11-24 13:06:31 +01:00
639cc6c737 Can send text message 2025-11-24 12:54:59 +01:00
bf119a34fb Can get room messages 2025-11-24 12:36:59 +01:00
7562a7fc61 Get latest message for a room 2025-11-24 11:20:20 +01:00
d23190f9d2 Can get spaces of user 2025-11-21 18:38:20 +01:00
35b53fee5c Can request any media file 2025-11-21 17:55:09 +01:00
934e6a4cc1 Can get multiple profiles information 2025-11-21 17:49:41 +01:00
b744265242 Can get single profile information 2025-11-21 17:14:23 +01:00
e8ce97eea0 Can get room avatar 2025-11-21 15:43:15 +01:00
ecbe4885c1 Can get information about rooms 2025-11-21 14:52:21 +01:00
1385afc974 Add more information to websocket messages 2025-11-21 11:40:51 +01:00
8d2cea5f82 Refactor messages propagation 2025-11-21 10:30:48 +01:00
751e3b8654 Redact more events 2025-11-21 09:35:21 +01:00
24f06a78a9 Block WS access if Matrix account is not linked 2025-11-21 09:12:13 +01:00
7a590e882b Merge pull request 'Update Rust crate ruma to 0.14.0' (#118) from renovate/ruma-0.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-21 00:12:11 +00:00
9a643ced94 Update Rust crate ruma to 0.14.0
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-11-21 00:11:59 +00:00
6b70842b61 Display state in color 2025-11-20 19:31:17 +01:00
7203671b18 Pretty rendering of JSON messages 2025-11-20 19:30:09 +01:00
055ab3759c Remove frontend messages 2025-11-20 19:15:42 +01:00
3ecfc6b470 Add base debug WS route 2025-11-20 19:14:02 +01:00
a1b22699e9 Basic implementation of websocket 2025-11-20 16:06:00 +01:00
5f2a6478a7 Merge pull request 'Update Rust crate clap to 4.5.53' (#117) from renovate/clap-4.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-20 00:09:04 +00:00
1db929a31b Update Rust crate clap to 4.5.53
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-11-20 00:08:50 +00:00
0d8905d842 Can stop sync thread from UI 2025-11-19 18:41:26 +01:00
564e606ac7 Properly handle start sync thread issue 2025-11-19 17:15:54 +01:00
7b691962a0 Can get sync thread status 2025-11-19 16:34:00 +01:00
1e00d24a8b Can request sync thread stop 2025-11-19 15:51:15 +01:00
cfdf98b47a Matrix messages are broadcasted 2025-11-19 14:02:51 +01:00
75b6b224bc Notify Matrix manager directly if sync thread is terminated 2025-11-19 13:39:28 +01:00
07f6544a4a WIP sync thread implementation 2025-11-19 11:37:57 +01:00
5bf7c7f8df Do not start sync thread if user is disconnected 2025-11-19 10:49:26 +01:00
79d4482ea4 Sync threads can be interrupted 2025-11-19 10:27:46 +01:00
c9b703bea3 Ready to implement sync thread logic 2025-11-18 22:17:39 +01:00
5c13cffe08 Send broadcast message when an API token is deleted 2025-11-18 15:09:27 +01:00
b5832df746 Can delete API token from UI 2025-11-18 14:51:05 +01:00
0b2c4071e8 Merge pull request 'Update Rust crate clap to 4.5.52' (#116) from renovate/clap-4.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-18 00:10:34 +00:00
61ecfc5af1 Update Rust crate clap to 4.5.52
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-11-18 00:10:24 +00:00
661793f58d Merge pull request 'Update Rust crate actix-web to 4.12.0' (#115) from renovate/actix-web-4.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-17 00:09:32 +00:00
d253e73099 Update Rust crate actix-web to 4.12.0
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-11-17 00:09:22 +00:00
f0d3d311e9 Merge pull request 'Update Rust crate bytes to 1.11.0' (#114) from renovate/bytes-1.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-15 00:09:38 +00:00
592203aa4a Update Rust crate bytes to 1.11.0
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2025-11-15 00:09:30 +00:00
02e5575892 Display the list of API tokens 2025-11-14 09:07:22 +01:00
2683268042 Load the list of API tokens 2025-11-13 21:16:45 +01:00
72aaf7b082 Add token creation dialog 2025-11-13 21:03:38 +01:00
c8a48488fc Fix session disconnection issue by removing automatic refresh on client initialization 2025-11-13 18:38:27 +01:00
3b7b368e13 Attempt to fix session restoration issues 2025-11-12 08:14:16 +01:00
5ca126eef7 Split recovery key dialog in new file 2025-11-12 08:06:47 +01:00
7c78eb541e Fix example API client 2025-11-11 21:24:19 +01:00
8fdf1d57eb Create & list tokens 2025-11-11 21:19:54 +01:00
b10ec9ce92 Cleanup code 2025-11-11 17:58:24 +01:00
7925785c8b Fix issue 2025-11-11 17:19:23 +01:00
84c90ea033 Can set user recovery key from UI 2025-11-10 17:42:32 +01:00
a23d671376 Can set recovery key of user 2025-11-10 08:47:02 +01:00
4a72411d65 Return encryption recovery status on API 2025-11-10 08:32:17 +01:00
70a246355b Can disconnect user from UI 2025-11-06 21:33:09 +01:00
8bbbe7022f Automatically disconnect user when token is invalid 2025-11-06 21:18:27 +01:00
1ba5372468 Restore user session on restart 2025-11-06 18:58:43 +01:00
aeb35029c3 Merge pull request 'Update Rust crate sha2 to 0.11.0-rc.3' (#113) from renovate/sha2-0.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-06 00:11:57 +00:00
1dc56d5ec1 Update Rust crate sha2 to 0.11.0-rc.3
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-11-06 00:11:50 +00:00
1438e2de0e Can save Matrix session after authentication 2025-11-05 22:53:55 +01:00
1eaec9d319 Can finalize Matrix authentication 2025-11-05 19:32:11 +01:00
51b1ab380c Merge pull request 'Update Rust crate rust-embed to 8.9.0' (#112) from renovate/rust-embed-8.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-03 00:09:55 +00:00
b5abddaacb Update Rust crate rust-embed to 8.9.0
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2025-11-03 00:09:54 +00:00
102 changed files with 7086 additions and 1080 deletions

104
.drone.yml Normal file
View 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
View 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)

View File

@@ -2,3 +2,4 @@ storage
app_storage app_storage
.idea .idea
target target
static

File diff suppressed because it is too large Load Diff

View File

@@ -5,29 +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.53", 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 = "0.14.0" matrix-sdk = { version = "0.14.0" }
matrix-sdk-ui = "0.14.0"
url = "2.5.7" url = "2.5.7"
ractor = "0.15.9" ractor = "0.15.9"
serde_json = "1.0.145"
lazy-regex = "3.4.2"
actix-ws = "0.3.0"
infer = "0.19.0"
rust-embed = "8.9.0"
mime_guess = "2.0.5"

View 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"]

View File

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

View File

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

View File

@@ -0,0 +1,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 },
}

View File

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

View File

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

View File

@@ -0,0 +1,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())
}

View File

@@ -0,0 +1,88 @@
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::crypto::{AttachmentDecryptor, MediaEncryptionInfo};
use matrix_sdk::media::{MediaFormat, MediaRequestParameters, MediaThumbnailSettings};
use matrix_sdk::ruma::events::room::MediaSource;
use matrix_sdk::ruma::{OwnedMxcUri, UInt};
use std::io::{Cursor, Read};
#[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?;
// Decrypt file if needed
let media = if let MediaSource::Encrypted(file) = source {
let mut cursor = Cursor::new(media);
let mut decryptor =
AttachmentDecryptor::new(&mut cursor, MediaEncryptionInfo::from(*file))?;
let mut decrypted_data = Vec::new();
decryptor.read_to_end(&mut decrypted_data)?;
decrypted_data
} else {
media
};
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))
}

View File

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

View File

@@ -0,0 +1,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, &notifs).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, &notifs).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, &notifs).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))
}

View File

@@ -0,0 +1,32 @@
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());
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))
}

View 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;

View File

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

View File

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

View File

@@ -3,8 +3,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_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 {
@@ -16,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 {
@@ -28,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())
} }
} }

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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}")
}
}

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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.6",
"@mdi/react": "^1.6.1", "@mui/material": "^7.3.6",
"@mui/icons-material": "^7.3.5", "@mui/x-data-grid": "^8.20.0",
"@mui/material": "^7.3.5", "@mui/x-date-pickers": "^8.19.0",
"date-and-time": "^4.1.1",
"dayjs": "^1.11.19",
"emoji-picker-react": "^4.16.1",
"is-cidr": "^6.0.1",
"qrcode.react": "^4.2.0",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-router": "^7.9.5" "react-favicon": "^2.0.7",
"react-json-view-lite": "^2.5.0",
"react-router": "^7.10.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.36.0", "@eslint/js": "^9.39.1",
"@types/node": "^24.6.0", "@types/node": "^24.10.1",
"@types/react": "^19.1.16", "@types/react": "^19.2.7",
"@types/react-dom": "^19.1.9", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.0.4", "@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.36.0", "eslint": "^9.39.1",
"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.22",
"globals": "^16.4.0", "globals": "^16.5.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.45.0", "typescript-eslint": "^8.48.1",
"vite": "npm:rolldown-vite@7.1.14" "vite": "npm:rolldown-vite@7.2.10"
}, },
"overrides": { "overrides": {
"vite": "npm:rolldown-vite@7.1.14" "vite": "npm:rolldown-vite@7.2.10"
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,10 @@ export interface UserInfo {
time_update: number; time_update: number;
name: string; name: string;
email: string; email: string;
matrix_account_connected: boolean;
matrix_user_id?: string; matrix_user_id?: string;
matrix_device_id?: string;
matrix_recovery_state?: "Enabled" | "Disabled" | "Unknown" | "Incomplete";
} }
const TokenStateKey = "auth-state"; const TokenStateKey = "auth-state";
@@ -42,7 +45,7 @@ export class AuthApi {
uri: "/auth/start_oidc", uri: "/auth/start_oidc",
method: "GET", method: "GET",
}) })
).data; ).data as { url: string };
} }
/** /**
@@ -67,7 +70,7 @@ export class AuthApi {
uri: "/auth/info", uri: "/auth/info",
method: "GET", method: "GET",
}) })
).data; ).data as UserInfo;
} }
/** /**

View File

@@ -10,6 +10,38 @@ export class MatrixLinkApi {
uri: "/matrix_link/start_auth", uri: "/matrix_link/start_auth",
method: "POST", method: "POST",
}) })
).data; ).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 },
});
} }
} }

View File

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

View File

@@ -35,7 +35,7 @@ export class ServerApi {
uri: "/server/config", uri: "/server/config",
method: "GET", method: "GET",
}) })
).data; ).data as ServerConfig;
} }
/** /**

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

View 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";
}
}

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

View File

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

View File

@@ -0,0 +1,26 @@
import { APIClient } from "../ApiClient";
export interface UserProfile {
user_id: string;
display_name?: string;
avatar?: string;
}
export type UsersMap = Map<string, UserProfile>;
export class MatrixApiProfile {
/**
* Get multiple profiles information
*/
static async GetMultiple(ids: string[]): Promise<UsersMap> {
const list = (
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]));
}
}

View 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[];
}
}

View 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;
}
}

View File

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

View File

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

View File

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

View File

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

View 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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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: {

View File

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

View 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());
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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}
/>
);
}

View File

@@ -1,3 +1,7 @@
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
import { Button } from "@mui/material";
import { Link } from "react-router";
export function NotLinkedAccountMessage(): React.ReactElement { export function NotLinkedAccountMessage(): React.ReactElement {
return ( return (
<div <div
@@ -6,9 +10,17 @@ export function NotLinkedAccountMessage(): React.ReactElement {
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
flexDirection: "column",
}} }}
> >
Your Matrix account is not linked yet! <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> </div>
); );
} }

View 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>
);
}

View File

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

View File

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

View File

@@ -1,5 +1,3 @@
import { mdiMessageTextFast } from "@mdi/js";
import Icon from "@mdi/react";
import LogoutIcon from "@mui/icons-material/Logout"; import LogoutIcon from "@mui/icons-material/Logout";
import MenuIcon from "@mui/icons-material/Menu"; import MenuIcon from "@mui/icons-material/Menu";
import MenuOpenIcon from "@mui/icons-material/MenuOpen"; import MenuOpenIcon from "@mui/icons-material/MenuOpen";
@@ -13,6 +11,8 @@ import Toolbar from "@mui/material/Toolbar";
import Tooltip from "@mui/material/Tooltip"; import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import * as React from "react"; import * as React from "react";
import { AppIcon } from "../../icons/AppIcon";
import mdiMessageTextFast from "../../icons/message-text-fast.svg";
import { RouterLink } from "../RouterLink"; import { RouterLink } from "../RouterLink";
import { useUserInfo } from "./BaseAuthenticatedPage"; import { useUserInfo } from "./BaseAuthenticatedPage";
import ThemeSwitcher from "./ThemeSwitcher"; import ThemeSwitcher from "./ThemeSwitcher";
@@ -81,7 +81,11 @@ export default function DashboardHeader({
); );
return ( return (
<AppBar color="inherit" position="absolute" sx={{ displayPrint: "none" }}> <AppBar
color="inherit"
position="static"
sx={{ displayPrint: "none", overflow: "hidden" }}
>
<Toolbar sx={{ backgroundColor: "inherit", mx: { xs: -0.75, sm: -1 } }}> <Toolbar sx={{ backgroundColor: "inherit", mx: { xs: -0.75, sm: -1 } }}>
<Stack <Stack
direction="row" direction="row"
@@ -97,7 +101,7 @@ export default function DashboardHeader({
<RouterLink to="/"> <RouterLink to="/">
<Stack direction="row" alignItems="center"> <Stack direction="row" alignItems="center">
<LogoContainer> <LogoContainer>
<Icon path={mdiMessageTextFast} size="2em" /> <AppIcon src={mdiMessageTextFast} size="2em" />
</LogoContainer> </LogoContainer>
<Typography <Typography
variant="h6" variant="h6"

View File

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

View File

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

View File

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

View File

@@ -7,9 +7,10 @@ import DarkModeIcon from "@mui/icons-material/DarkMode";
import LightModeIcon from "@mui/icons-material/LightMode"; import LightModeIcon from "@mui/icons-material/LightMode";
import type {} from "@mui/material/themeCssVarsAugmentation"; import type {} from "@mui/material/themeCssVarsAugmentation";
export default function ThemeSwitcher() { export function useActualColorMode(): {
const theme = useTheme(); mode: "light" | "dark";
setMode: (mode: "light" | "dark") => void;
} {
const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)"); const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
const preferredMode = prefersDarkMode ? "dark" : "light"; const preferredMode = prefersDarkMode ? "dark" : "light";
@@ -17,21 +18,27 @@ export default function ThemeSwitcher() {
const paletteMode = !mode || mode === "system" ? preferredMode : mode; 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(() => { const toggleMode = React.useCallback(() => {
setMode(paletteMode === "dark" ? "light" : "dark"); setMode(mode === "dark" ? "light" : "dark");
}, [setMode, paletteMode]); }, [mode, setMode]);
return ( return (
<Tooltip <Tooltip
title={`${paletteMode === "dark" ? "Light" : "Dark"} mode`} title={`${mode === "dark" ? "Light" : "Dark"} mode`}
enterDelay={1000} enterDelay={1000}
> >
<div> <div>
<IconButton <IconButton
size="small" size="small"
aria-label={`Switch to ${ aria-label={`Switch to ${mode === "dark" ? "light" : "dark"} mode`}
paletteMode === "dark" ? "light" : "dark"
} mode`}
onClick={toggleMode} onClick={toggleMode}
> >
<LightModeIcon <LightModeIcon

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,35 @@
import Favicon from "react-favicon";
import { WSState } from "./MatrixWS";
// Taken from https://github.com/element-hq/element-web/blob/0577e245dac944bd85eea07b93a9762a93062f62/src/favicon.ts
function getInitialFavicon(): HTMLLinkElement[] {
const icons: HTMLLinkElement[] = [];
const links = window.document
.getElementsByTagName("head")[0]
.getElementsByTagName("link");
for (const link of links) {
if (
link.hasAttribute("rel") &&
/(^|\s)icon(\s|$)/i.test(link.getAttribute("rel")!)
) {
icons.push(link);
}
}
return icons;
}
const iconPath = getInitialFavicon()[0].getAttribute("href")!;
export function AppIconModifier(p: {
numberUnread: number;
state: string;
}): React.ReactElement {
const isError = p.state === WSState.Error || p.state === WSState.Closed;
return (
<Favicon
url={iconPath}
alertFillColor={isError ? "orange" : undefined}
alertCount={isError ? "x" : p.numberUnread}
/>
);
}

View File

@@ -0,0 +1,210 @@
import { Divider } from "@mui/material";
import React from "react";
import { MatrixApiEvent } from "../../api/matrix/MatrixApiEvent";
import {
MatrixApiProfile,
type UsersMap,
} from "../../api/matrix/MatrixApiProfile";
import { MatrixApiRoom, type Room } from "../../api/matrix/MatrixApiRoom";
import {
MatrixApiSpace,
type SpaceHierarchy,
} from "../../api/matrix/MatrixApiSpace";
import { MatrixSyncApi } from "../../api/MatrixSyncApi";
import type { WsMessage } from "../../api/WsApi";
import { RoomEventsManager } from "../../utils/RoomEventsManager";
import { AsyncWidget } from "../AsyncWidget";
import { useUserInfo } from "../dashboard/BaseAuthenticatedPage";
import { AppIconModifier } from "./AppIconModifier";
import { MatrixWS } from "./MatrixWS";
import { RoomSelector } from "./RoomSelector";
import { RoomWidget } from "./RoomWidget";
import { SpaceSelector } from "./SpaceSelector";
export function MainMessageWidget(): React.ReactElement {
const [rooms, setRooms] = React.useState<Room[] | undefined>();
const [hierarchy, setHierarchy] = React.useState<
SpaceHierarchy | undefined
>();
const [users, setUsers] = React.useState<UsersMap | undefined>();
const loadRoomsList = async () => {
await MatrixSyncApi.Start();
const rooms = await MatrixApiRoom.ListJoined();
const hierarchy = await MatrixApiSpace.Hierarchy();
setRooms(rooms);
setHierarchy(hierarchy);
// Get the list of users in rooms
const users = rooms.reduce((prev, r) => {
r.members.forEach((m) => prev.add(m));
return prev;
}, new Set<string>());
setUsers(await MatrixApiProfile.GetMultiple([...users]));
};
return (
<AsyncWidget
loadKey={1}
load={loadRoomsList}
ready={!!rooms && !!users && !!hierarchy}
errMsg="Failed to initialize messaging component!"
build={() => (
<MainMessageWidgetInner
rooms={rooms!}
hierarchy={hierarchy!}
users={users!}
onRoomsListUpdate={(cb) => setRooms((r) => cb(r!))}
/>
)}
/>
);
}
function MainMessageWidgetInner(p: {
rooms: Room[];
hierarchy: SpaceHierarchy;
users: UsersMap;
onRoomsListUpdate: (cb: (a: Room[]) => Room[]) => void;
}): React.ReactElement {
const user = useUserInfo();
const [space, setSpace] = React.useState<string | undefined>();
const [currentRoom, setCurrentRoom] = React.useState<Room | undefined>();
const spaceRooms = React.useMemo(() => {
return p.rooms
.filter(
(r) => !r.is_space && (!space || p.hierarchy.get(space)?.includes(r.id))
)
.sort(
(a, b) => (b.latest_event?.time ?? 0) - (a.latest_event?.time ?? 0)
);
}, [space, p.rooms, p.hierarchy]);
const unreadRooms = React.useMemo(
() =>
p.rooms.filter(
(r) => r.number_unread_messages > 0 && r.notifications === "AllMessages"
).length,
[p.rooms]
);
const setRefreshCount = React.useState(0)[1];
const [roomMgr, setRoomMgr] = React.useState<undefined | RoomEventsManager>();
const loadRoom = async () => {
setRoomMgr(undefined);
if (!currentRoom) {
console.warn("Cannot load manager for no room!");
return;
}
const messages = await MatrixApiEvent.GetRoomEvents(currentRoom);
const receipts = await MatrixApiRoom.RoomReceipts(currentRoom);
const mgr = new RoomEventsManager(currentRoom!, messages, receipts);
setRoomMgr(mgr);
};
const [wsState, setWsState] = React.useState("");
const handleWsEvent = (m: WsMessage) => {
// Process messages for current room
if (roomMgr?.processWsMessage(m)) {
console.info("Current room updated!");
setRefreshCount((c) => c + 1);
}
// Add a new unread message on left sidebar
if (
m.type === "RoomMessageEvent" &&
!m.data["m.new_content"] &&
m.sender !== user.info.matrix_user_id
) {
p.onRoomsListUpdate((r) => {
const n = [...r];
const idx = r.findIndex((el) => el.id === m.room_id);
if (idx && n[idx]?.notifications === "AllMessages")
n[idx] = {
...n[idx],
number_unread_messages: n[idx].number_unread_messages + 1,
};
return n;
});
}
// Remove unread message on left sidebar
if (
m.type === "ReceiptEvent" &&
m.receipts.find((r) => r.user === user.info.matrix_user_id) !== undefined
) {
p.onRoomsListUpdate((r) => {
const n = [...r];
const idx = r.findIndex((el) => el.id === m.room_id);
if (idx)
n[idx] = {
...n[idx],
number_unread_messages: 0,
};
return n;
});
}
};
return (
<div style={{ display: "flex", height: "100%" }}>
{/* Websocket */}
<div style={{ position: "absolute", right: "0px", padding: "10px" }}>
<MatrixWS onMessage={handleWsEvent} onStateChange={setWsState} />
</div>
{/** Application icon modifier */}
<AppIconModifier numberUnread={unreadRooms} state={wsState} />
{/* Space selector */}
<SpaceSelector {...p} selectedSpace={space} onChange={setSpace} />
{/* Separator */}
<Divider orientation="vertical" />
{/* Room selector */}
<RoomSelector
{...p}
rooms={spaceRooms}
currRoom={currentRoom}
onChange={setCurrentRoom}
/>
{/* Separator */}
<Divider orientation="vertical" />
{/* If no room is selected */}
{currentRoom === undefined && (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
flex: 1,
}}
>
No room selected.
</div>
)}
{/* In case of room */}
{currentRoom && (
<AsyncWidget
loadKey={currentRoom.id}
ready={!!roomMgr}
load={loadRoom}
errMsg="Failed to load room!"
build={() => (
<RoomWidget {...p} manager={roomMgr!} room={currentRoom} />
)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,93 @@
import React from "react";
import { WsApi, type WsMessage } from "../../api/WsApi";
import { useSnackbar } from "../../hooks/contexts_provider/SnackbarProvider";
import CircleIcon from "@mui/icons-material/Circle";
import { Tooltip } from "@mui/material";
export const WSState = {
Closed: "Closed",
Connected: "Connected",
Error: "Error",
} as const;
export function MatrixWS(p: {
onMessage: (msg: WsMessage) => void;
onStateChange?: (state: string) => void;
}): React.ReactElement {
const snackbar = useSnackbar();
// Keep only the latest version of onMessage
const cbRef = React.useRef(p.onMessage);
React.useEffect(() => {
cbRef.current = p.onMessage;
}, [p.onMessage]);
// Keep only the latest version of onStateChange
const stateCbRef = React.useRef(p.onStateChange);
React.useEffect(() => {
stateCbRef.current = p.onStateChange;
}, [p.onStateChange]);
const [state, setState] = React.useState<string>(WSState.Closed);
const wsId = React.useRef<number | undefined>(undefined);
const [connCount, setConnCount] = React.useState(0);
React.useEffect(() => {
const id = Math.random();
const ws = new WebSocket(WsApi.WsURL);
wsId.current = id;
// Open
ws.onopen = () => {
if (wsId.current != id) return;
setState(WSState.Connected);
stateCbRef.current?.(WSState.Connected);
};
// Error
ws.onerror = (e) => {
if (wsId.current != id) return;
console.error(`WS Debug error!`, e);
snackbar(`WebSocket error!`);
setState(WSState.Error);
stateCbRef.current?.(WSState.Error);
setTimeout(() => setConnCount(connCount + 1), 500);
};
// Close
ws.onclose = () => {
if (wsId.current !== id) return;
setState(WSState.Closed);
stateCbRef.current?.(WSState.Closed);
wsId.current = undefined;
};
// Message
ws.onmessage = (msg) => {
if (wsId.current !== id) return;
const dec = JSON.parse(msg.data);
console.info("WS message", dec);
cbRef.current(dec);
};
return () => ws.close();
}, [connCount, snackbar]);
return (
<Tooltip title={state}>
<CircleIcon
color={
state === WSState.Connected
? "success"
: state === WSState.Error
? "error"
: undefined
}
/>
</Tooltip>
);
}

View File

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

View File

@@ -0,0 +1,627 @@
import AddReactionIcon from "@mui/icons-material/AddReaction";
import DeleteIcon from "@mui/icons-material/Delete";
import DownloadIcon from "@mui/icons-material/Download";
import EditIcon from "@mui/icons-material/Edit";
import {
Box,
Button,
ButtonGroup,
Chip,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
TextField,
Tooltip,
Typography,
useTheme,
} from "@mui/material";
import EmojiPicker, { EmojiStyle, Theme } from "emoji-picker-react";
import React from "react";
import { MatrixApiEvent } from "../../api/matrix/MatrixApiEvent";
import type { UsersMap } from "../../api/matrix/MatrixApiProfile";
import type { Receipt, Room } from "../../api/matrix/MatrixApiRoom";
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 type {
Message,
MessageReaction,
RoomEventsManager,
} from "../../utils/RoomEventsManager";
import { useUserInfo } from "../dashboard/BaseAuthenticatedPage";
import { EmojiIcon } from "../EmojiIcon";
import { AccountIcon } from "./AccountIcon";
export function RoomMessagesList(p: {
room: Room;
users: UsersMap;
manager: RoomEventsManager;
}): React.ReactElement {
const snackbar = useSnackbar();
const [loadingOlder, setLoadingOlder] = React.useState(false);
const listContainerRef = React.createRef<HTMLDivElement>();
const messagesEndRef = React.createRef<HTMLDivElement>();
// Automatically scroll to bottom when number of messages change
const lastEventId = p.manager.messages.at(-1)?.event_id;
React.useEffect(() => {
if (messagesEndRef)
messagesEndRef.current?.scrollIntoView({ behavior: "instant" });
}, [lastEventId, messagesEndRef]);
const loadOlderMessages = async () => {
if (loadingOlder || !p.manager.canLoadOlder) return;
setLoadingOlder(true);
try {
const older = await MatrixApiEvent.GetRoomEvents(
p.room,
p.manager.endToken
);
p.manager.processNewEvents(older);
} catch (e) {
console.error("Failed to load older messages!", e);
snackbar(`Failed to load older messages for conversation! ${e}`);
} finally {
setLoadingOlder(false);
}
};
// Watch scroll to detect when user reach the top to load older messages
const handleScroll = async () => {
if (!listContainerRef.current) return;
const { scrollTop } = listContainerRef.current;
if (scrollTop !== 0) {
return;
}
loadOlderMessages();
};
return (
<div
onScroll={handleScroll}
ref={listContainerRef}
style={{
flex: 1,
width: "100%",
paddingRight: "50px",
overflow: "scroll",
paddingLeft: "20px",
}}
>
{/** Begining of conversation */}
{!p.manager.canLoadOlder && p.manager.messages.length > 0 && (
<Typography
component={"div"}
variant="caption"
style={{ textAlign: "center", marginTop: "10px" }}
>
Begining of conversation
</Typography>
)}
{/** Load older messages button */}
{p.manager.canLoadOlder && !loadingOlder && (
<div
style={{
display: "inline-flex",
justifyContent: "center",
width: "100%",
padding: "20px",
}}
>
<Button onClick={loadOlderMessages} variant="outlined">
Load older messages
</Button>
</div>
)}
{/** Loading older messages spinner */}
{loadingOlder && (
<div
style={{
display: "inline-flex",
justifyContent: "center",
width: "100%",
padding: "20px",
}}
>
<CircularProgress />
</div>
)}
{/* Empty conversation notice */}
{p.manager.messages.length === 0 && (
<div
style={{
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
No message in this conversation yet!
</div>
)}
{/** Messages themselves */}
{p.manager.messages.map((m, idx) => (
<RoomMessage
key={m.event_id}
{...p}
message={m}
previousFromSamePerson={
idx > 0 &&
p.manager.messages[idx - 1].account === m.account &&
m.time_sent - p.manager.messages[idx - 1].time_sent < 60 * 3 * 1000
}
firstMessageOfDay={
idx === 0 ||
m.time_sent_dayjs.startOf("day").unix() !=
p.manager.messages[idx - 1].time_sent_dayjs.startOf("day").unix()
}
receipts={p.manager.receiptsEventsMap.get(m.event_id)}
repliedMessage={
(m.inReplyTo &&
p.manager.messages.find((s) => s.event_id === m.inReplyTo)) ||
undefined
}
/>
))}
<div ref={messagesEndRef} style={{ height: "10px" }} />
</div>
);
}
function RoomMessage(p: {
room: Room;
users: UsersMap;
message: Message;
previousFromSamePerson: boolean;
firstMessageOfDay: boolean;
receipts?: Receipt[];
repliedMessage?: Message;
}): React.ReactElement {
const theme = useTheme();
const user = useUserInfo();
const alert = useAlert();
const confirm = useConfirm();
const snackbar = useSnackbar();
const loadingMessage = useLoadingMessage();
const [showImageFullScreen, setShowImageFullScreen] = React.useState(false);
const [editMessage, setEditMessage] = React.useState<string | undefined>();
const [pickReaction, setPickReaction] = React.useState(false);
const closeImageFullScreen = () => setShowImageFullScreen(false);
const sender = p.users.get(p.message.account);
const repliedMsgSender =
p.repliedMessage && p.users.get(p.repliedMessage.account);
const handleDeleteMessage = async () => {
if (!(await confirm(`Do you really want to delete this message?`))) return;
try {
await MatrixApiEvent.DeleteEvent(p.room, p.message.event_id);
} catch (e) {
console.error(`Failed to delete message!`, e);
alert(`Failed to delete message!${e}`);
}
};
const handleEditMessage = () => setEditMessage(p.message.content);
const handleCancelEditMessage = () => setEditMessage(undefined);
const handleSubmitEditMessage = async (event: React.FormEvent) => {
event.preventDefault();
try {
loadingMessage.show(`Updating message content...`);
await MatrixApiEvent.SetTextMessageContent(
p.room,
p.message.event_id,
editMessage!
);
setEditMessage(undefined);
} catch (e) {
console.error(`Failed to edit message!`, e);
alert(`Failed to edit message content! ${e}`);
} finally {
loadingMessage.hide();
}
};
const handleAddReaction = () => setPickReaction(true);
const handleCancelAddReaction = () => setPickReaction(false);
const handleSelectEmoji = async (key: string) => {
loadingMessage.show("Setting reaction...");
try {
await MatrixApiEvent.ReactToEvent(p.room, p.message.event_id, key);
setPickReaction(false);
} catch (e) {
console.error("Failed to select emoji!", e);
alert(`Failed to select emoji! ${e}`);
} finally {
loadingMessage.hide();
}
};
const handleToggleReaction = async (
key: string,
reaction: MessageReaction | undefined
) => {
try {
if (!reaction)
await MatrixApiEvent.ReactToEvent(p.room, p.message.event_id, key);
else await MatrixApiEvent.DeleteEvent(p.room, reaction.event_id);
} catch (e) {
console.error(`Failed to toggle reaction!`, e);
snackbar(`Failed to toggle reaction! ${e}`);
}
};
return (
<>
{/* Print date if required */}
{p.firstMessageOfDay && (
<Typography
variant="caption"
component={"div"}
style={{ textAlign: "center", marginTop: "50px" }}
>
{p.message.time_sent_dayjs.format("DD/MM/YYYY")}
</Typography>
)}
{/* Give person name if required */}
{(!p.previousFromSamePerson || p.firstMessageOfDay) && sender && (
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
marginTop: "20px",
}}
>
<AccountIcon user={sender} />
&nbsp;&nbsp;&nbsp;
{sender.display_name}
</div>
)}
{/* Message content */}
<Box
style={{
wordBreak: "break-all",
wordWrap: "break-word",
maxWidth: "100%",
transition: "all 0.01s ease-in",
position: "relative",
display: "flex",
flexDirection: "row",
}}
component="div"
sx={{
[theme.getColorSchemeSelector("dark") + "&:hover"]: {
backgroundColor: "#ffffff2b",
},
[theme.getColorSchemeSelector("light") + "&:hover"]: {
backgroundColor: "#00000039",
},
"&:hover *": { visibility: "visible" },
}}
>
<Typography
variant="caption"
style={{
paddingLeft: "2px",
display: "inline-flex",
alignItems: "center",
}}
>
{p.message.time_sent_dayjs.format("HH:mm")}
</Typography>
{/** Message itself */}
<div style={{ marginLeft: "15px", whiteSpace: "pre-wrap", flex: 1 }}>
{/** In case of reply */}
{p.repliedMessage && repliedMsgSender && (
<div
style={{
display: "inline-flex",
alignItems: "center",
borderLeft: "1px red solid",
paddingLeft: "10px",
overflow: "hidden",
}}
>
<AccountIcon user={repliedMsgSender} size={16} />
<div style={{ marginLeft: "10px" }}>
{p.repliedMessage?.content}
</div>
</div>
)}
{/* Image */}
{p.message.type === "m.image" && (
<img
onClick={() => setShowImageFullScreen(true)}
src={MatrixApiEvent.GetEventFileURL(
p.room,
p.message.event_id,
true
)}
style={{
maxWidth: "200px",
}}
/>
)}
{/* Audio */}
{p.message.type === "m.audio" && (
<audio controls>
<source
src={MatrixApiEvent.GetEventFileURL(
p.room,
p.message.event_id,
false
)}
/>
</audio>
)}
{/* Video */}
{p.message.type === "m.video" && (
<video controls style={{ maxHeight: "300px", maxWidth: "300px" }}>
<source
src={MatrixApiEvent.GetEventFileURL(
p.room,
p.message.event_id,
false
)}
/>
</video>
)}
{/* File */}
{p.message.type === "m.file" && (
<a
href={MatrixApiEvent.GetEventFileURL(
p.room,
p.message.event_id,
false
)}
target="_blank"
rel="noopener"
>
<Button variant="outlined" startIcon={<DownloadIcon />}>
{p.message.content}
</Button>
</a>
)}
{/* Text message */}
{p.message.type === "m.text" && (
<div style={{ margin: "2px 0px" }}>{p.message.content}</div>
)}
</div>
{/* Read receipts */}
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
{(p.receipts ?? []).map((r) => {
const u = p.users.get(r.user);
if (!u || u.user_id === user.info.matrix_user_id) return <></>;
return (
<div style={{ marginRight: "2px" }}>
<AccountIcon key={u.user_id} user={u} size={16} />
</div>
);
})}
</div>
{/** Button bar */}
<ButtonGroup
className="buttons"
size="small"
style={{
position: "absolute",
visibility: "hidden",
display: "block",
top: "-34px",
right: "0px",
}}
>
{/* Common reactions */}
<ReactionButton {...p} emojiKey="👍" /> {/* 👍 */}
<ReactionButton {...p} emojiKey="♥️" /> {/* ♥️ */}
<ReactionButton {...p} emojiKey="😂" /> {/* 😂 */}
{/* Add reaction */}
<Button onClick={handleAddReaction}>
<AddReactionIcon />
</Button>
{/* Edit text message */}
{p.message.account === user.info.matrix_user_id &&
!p.message.file && (
<Button onClick={handleEditMessage}>
<EditIcon />
</Button>
)}
{/* Delete message */}
{p.message.account === user.info.matrix_user_id && (
<Button onClick={handleDeleteMessage}>
<DeleteIcon color="error" />
</Button>
)}
</ButtonGroup>
</Box>
{/* Reactions */}
<Box sx={{ marginLeft: "50px" }}>
{[...p.message.reactions.keys()].map((r) => {
const reactions = p.message.reactions.get(r)!;
const userReaction = reactions.find(
(r) => r.account === user.info.matrix_user_id
);
return (
<Tooltip
enterDelay={50}
placement="top"
arrow
title={
<span style={{ whiteSpace: "pre-wrap" }}>
{reactions
.map((r) => p.users.get(r.account)?.display_name)
.join("\n")}
</span>
}
>
<Chip
size="small"
style={{
height: "2em",
marginRight: "5px",
maxHeight: "unset",
cursor: "pointer",
}}
slotProps={{
root: {
onClick: () => handleToggleReaction(r, userReaction),
},
label: { style: { height: "2em" } },
}}
color={userReaction !== undefined ? "success" : undefined}
variant="filled"
label={
<span
style={{
display: "inline-flex",
justifyContent: "center",
alignItems: "center",
flexDirection: "row",
}}
>
<div style={{ margin: "0px 3px" }}>
<EmojiIcon emojiKey={r} size={16} />
</div>
<div style={{ marginLeft: "2px" }}>{reactions.length}</div>
</span>
}
/>
</Tooltip>
);
})}
</Box>
{/* Full screen image dialog */}
<Dialog open={showImageFullScreen} onClose={closeImageFullScreen}>
<img
src={MatrixApiEvent.GetEventFileURL(
p.room,
p.message.event_id,
false
)}
/>
</Dialog>
{/* Pick reaction dialog */}
<Dialog open={pickReaction} onClose={handleCancelAddReaction}>
<EmojiPicker
emojiStyle={EmojiStyle.NATIVE}
theme={Theme.AUTO}
onEmojiClick={(emoji) => handleSelectEmoji(emoji.emoji)}
/>
</Dialog>
{/* Edit message dialog */}
<Dialog open={!!editMessage} onClose={handleCancelEditMessage} fullWidth>
<DialogTitle>Edit message content</DialogTitle>
<DialogContent>
<DialogContentText>Enter new message content:</DialogContentText>
<form
onSubmit={handleSubmitEditMessage}
id={`edit-message-${p.message.event_id}`}
>
<TextField
autoFocus
required
margin="dense"
label="New content"
type="text"
fullWidth
variant="standard"
multiline
value={editMessage}
onChange={(e) => setEditMessage(e.target.value)}
/>
</form>
</DialogContent>
<DialogActions>
<Button onClick={handleCancelEditMessage}>Cancel</Button>
<Button type="submit" form={`edit-message-${p.message.event_id}`}>
Edit
</Button>
</DialogActions>
</Dialog>
</>
);
}
function ReactionButton(p: {
room: Room;
message: Message;
emojiKey: string;
}): React.ReactElement {
const alert = useAlert();
const user = useUserInfo();
const sendEmoji = async () => {
try {
await MatrixApiEvent.ReactToEvent(p.room, p.message.event_id, p.emojiKey);
} catch (e) {
console.error("Failed to send reaction!", e);
alert(`Failed to send reaction! ${e}`);
}
};
// Do not offer to react to existing reactions
if (
p.message.reactions
.get(p.emojiKey)
?.find(
(r) => r.key === p.emojiKey && r.account === user.info.matrix_user_id
) !== undefined
)
return <></>;
return (
<Button
onClick={sendEmoji}
sx={{
paddingTop: "1px !important",
display: "inline-flex",
alignItems: "start",
}}
>
<EmojiIcon {...p} />
</Button>
);
}

View File

@@ -0,0 +1,128 @@
import SearchIcon from "@mui/icons-material/Search";
import {
Chip,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
TextField,
} from "@mui/material";
import React from "react";
import type { UsersMap } from "../../api/matrix/MatrixApiProfile";
import { roomName, type Room } from "../../api/matrix/MatrixApiRoom";
import { useUserInfo } from "../dashboard/BaseAuthenticatedPage";
import { RoomIcon } from "./RoomIcon";
const ROOM_SELECTOR_WIDTH = "300px";
export function RoomSelector(p: {
users: UsersMap;
rooms: Room[];
currRoom?: Room;
onChange: (r: Room) => void;
}): React.ReactElement {
const user = useUserInfo();
const [filter, setFilter] = React.useState("");
const [unread, setUnread] = React.useState(false);
const shownRooms = React.useMemo(
() =>
p.rooms
.filter((r) => !unread || r.number_unread_messages > 0)
.filter(
(r) =>
filter === "" ||
r.name?.toLocaleLowerCase()?.includes(filter.toLocaleLowerCase())
),
[p.rooms, unread, filter]
);
if (p.rooms.length === 0)
return (
<div
style={{
width: ROOM_SELECTOR_WIDTH,
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
No room to display.
</div>
);
return (
<div style={{ display: "flex", flexDirection: "column" }}>
{/** Filter bar */}
<TextField
placeholder="Filter rooms"
slotProps={{
input: {
startAdornment: <SearchIcon style={{ marginRight: "10px" }} />,
},
}}
style={{ margin: "5px" }}
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
{/** Chip bar */}
<div style={{ padding: "5px 10px", marginTop: "5px" }}>
<span onClick={() => setUnread(!unread)} style={{ cursor: "pointer" }}>
<Chip
label="Unread"
size="medium"
color={unread ? "success" : undefined}
variant="outlined"
/>
</span>
</div>
{/** Rooms list */}
<List
style={{
flex: 1,
width: ROOM_SELECTOR_WIDTH,
overflow: "scroll",
}}
>
{shownRooms.map((r) => (
<ListItem
key={r.id}
secondaryAction={
r.number_unread_messages === 0 ? undefined : (
<Chip color="error" label={r.number_unread_messages} />
)
}
disablePadding
>
<ListItemButton
role={undefined}
onClick={() => p.onChange(r)}
dense
selected={p.currRoom?.id === r.id}
>
<ListItemIcon>
<RoomIcon room={r} {...p} />
</ListItemIcon>
<ListItemText
primary={
<span
style={{
fontWeight:
r.number_unread_messages > 0 ? "bold" : undefined,
}}
>
{roomName(user.info, r, p.users)}
</span>
}
/>
</ListItemButton>
</ListItem>
))}
</List>
</div>
);
}

View File

@@ -0,0 +1,44 @@
import React from "react";
import { MatrixApiEvent } from "../../api/matrix/MatrixApiEvent";
import type { UsersMap } from "../../api/matrix/MatrixApiProfile";
import type { Room } from "../../api/matrix/MatrixApiRoom";
import { useSnackbar } from "../../hooks/contexts_provider/SnackbarProvider";
import { RoomEventsManager } from "../../utils/RoomEventsManager";
import { RoomMessagesList } from "./RoomMessagesList";
import { SendMessageForm } from "./SendMessageForm";
import { TypingNotice } from "./TypingNotice";
export function RoomWidget(p: {
room: Room;
users: UsersMap;
manager: RoomEventsManager;
}): React.ReactElement {
const snackbar = useSnackbar();
const receiptId = React.useRef<string | undefined>(undefined);
const handleRoomClick = async () => {
if (p.manager.messages.length === 0) return;
const latest = p.manager.messages[p.manager.messages.length - 1];
if (latest.event_id === receiptId.current) return;
receiptId.current = latest.event_id;
try {
await MatrixApiEvent.SendReceipt(p.room, latest.event_id);
} catch (e) {
console.error("Failed to send read receipt!", e);
snackbar(`Failed to send read receipt! ${e}`);
}
};
return (
<div
style={{ display: "flex", flexDirection: "column", flex: 1 }}
onClick={handleRoomClick}
>
<RoomMessagesList {...p} />
<TypingNotice {...p} />
<SendMessageForm {...p} />
</div>
);
}

View File

@@ -0,0 +1,63 @@
import SendIcon from "@mui/icons-material/Send";
import { IconButton, TextField } from "@mui/material";
import React, { type FormEvent } from "react";
import { MatrixApiEvent } from "../../api/matrix/MatrixApiEvent";
import type { Room } from "../../api/matrix/MatrixApiRoom";
import { useAlert } from "../../hooks/contexts_provider/AlertDialogProvider";
import { useLoadingMessage } from "../../hooks/contexts_provider/LoadingMessageProvider";
export function SendMessageForm(p: { room: Room }): React.ReactElement {
const loadingMessage = useLoadingMessage();
const alert = useAlert();
const [text, setText] = React.useState("");
const handleTextSubmit = async (e: FormEvent) => {
e.preventDefault();
if (text === "") return;
loadingMessage.show("Sending message...");
try {
await MatrixApiEvent.SendTextMessage(p.room, text);
setText("");
} catch (e) {
console.error(`Failed to send message! ${e}`);
alert(`Failed to send message! ${e}`);
} finally {
loadingMessage.hide();
}
};
return (
<form onSubmit={handleTextSubmit}>
<div
style={{
padding: "10px",
paddingLeft: "20px",
display: "flex",
flexDirection: "row",
}}
>
<TextField
placeholder="Send a message..."
variant="standard"
fullWidth
style={{}}
slotProps={{ input: { disableUnderline: true } }}
value={text}
onChange={(e) => setText(e.target.value)}
/>
<IconButton
size="small"
style={{ visibility: text === "" ? "hidden" : "visible" }}
onClick={handleTextSubmit}
>
<SendIcon />
</IconButton>
</div>
</form>
);
}

Some files were not shown because too many files have changed in this diff Show More