289 Commits

Author SHA1 Message Date
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
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
6b70842b61 Display state in color 2025-11-20 19:31:17 +01:00
7203671b18 Pretty rendering of JSON messages 2025-11-20 19:30:09 +01:00
055ab3759c Remove frontend messages 2025-11-20 19:15:42 +01:00
3ecfc6b470 Add base debug WS route 2025-11-20 19:14:02 +01:00
a1b22699e9 Basic implementation of websocket 2025-11-20 16:06:00 +01:00
0d8905d842 Can stop sync thread from UI 2025-11-19 18:41:26 +01:00
564e606ac7 Properly handle start sync thread issue 2025-11-19 17:15:54 +01:00
7b691962a0 Can get sync thread status 2025-11-19 16:34:00 +01:00
1e00d24a8b Can request sync thread stop 2025-11-19 15:51:15 +01:00
cfdf98b47a Matrix messages are broadcasted 2025-11-19 14:02:51 +01:00
75b6b224bc Notify Matrix manager directly if sync thread is terminated 2025-11-19 13:39:28 +01:00
07f6544a4a WIP sync thread implementation 2025-11-19 11:37:57 +01:00
5bf7c7f8df Do not start sync thread if user is disconnected 2025-11-19 10:49:26 +01:00
79d4482ea4 Sync threads can be interrupted 2025-11-19 10:27:46 +01:00
c9b703bea3 Ready to implement sync thread logic 2025-11-18 22:17:39 +01:00
5c13cffe08 Send broadcast message when an API token is deleted 2025-11-18 15:09:27 +01:00
b5832df746 Can delete API token from UI 2025-11-18 14:51:05 +01:00
02e5575892 Display the list of API tokens 2025-11-14 09:07:22 +01:00
2683268042 Load the list of API tokens 2025-11-13 21:16:45 +01:00
72aaf7b082 Add token creation dialog 2025-11-13 21:03:38 +01:00
c8a48488fc Fix session disconnection issue by removing automatic refresh on client initialization 2025-11-13 18:38:27 +01:00
3b7b368e13 Attempt to fix session restoration issues 2025-11-12 08:14:16 +01:00
5ca126eef7 Split recovery key dialog in new file 2025-11-12 08:06:47 +01:00
7c78eb541e Fix example API client 2025-11-11 21:24:19 +01:00
8fdf1d57eb Create & list tokens 2025-11-11 21:19:54 +01:00
b10ec9ce92 Cleanup code 2025-11-11 17:58:24 +01:00
7925785c8b Fix issue 2025-11-11 17:19:23 +01:00
84c90ea033 Can set user recovery key from UI 2025-11-10 17:42:32 +01:00
a23d671376 Can set recovery key of user 2025-11-10 08:47:02 +01:00
4a72411d65 Return encryption recovery status on API 2025-11-10 08:32:17 +01:00
70a246355b Can disconnect user from UI 2025-11-06 21:33:09 +01:00
8bbbe7022f Automatically disconnect user when token is invalid 2025-11-06 21:18:27 +01:00
1ba5372468 Restore user session on restart 2025-11-06 18:58:43 +01:00
1438e2de0e Can save Matrix session after authentication 2025-11-05 22:53:55 +01:00
1eaec9d319 Can finalize Matrix authentication 2025-11-05 19:32:11 +01:00
37fad9ff55 Can start Matrix authentication from UI 2025-11-05 18:27:41 +01:00
3dab9f41d2 Start Matrix client authentication 2025-11-05 16:30:06 +01:00
a44327ddb0 Display user name in application header 2025-11-05 08:31:47 +01:00
f9fb99cdb5 Add TODO marker 2025-11-04 22:04:12 +01:00
3de26c0fff Simplify dashboard code 2025-11-04 22:02:56 +01:00
79b5a767f3 Improve sidebar 2025-11-04 21:51:20 +01:00
fdcd565431 Add base authenticated route 2025-11-04 21:31:54 +01:00
d9c96e85f7 Authentication flow is functional 2025-11-04 20:51:07 +01:00
9ed711777c Add webapp skeletons 2025-11-04 19:43:51 +01:00
20a42f3c55 Add custom theme 2025-11-04 19:20:17 +01:00
d05747e60e Add base web app 2025-11-04 18:58:41 +01:00
1cdd3d9e60 Add backend README 2025-11-03 22:28:38 +01:00
bc815a5cf1 Add users authentication routes 2025-11-03 22:17:37 +01:00
830f47b61f Add server config route 2025-11-03 19:13:42 +01:00
602edaae79 Start to build backend base 2025-10-31 18:29:31 +01:00
f7e1d1539f Reset repository content 2025-10-31 17:59:20 +01:00
2f74c3b1a6 Integrate Matrix Authentication Service in test environment
Some checks failed
continuous-integration/drone/push Build is failing
2025-10-31 16:31:45 +01:00
d2791f821f Merge pull request 'Update Rust crate jwt-simple to 0.12.13' (#110) from renovate/jwt-simple-0.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-10-31 00:36:36 +00:00
4a7eb57cf3 Update Rust crate jwt-simple to 0.12.13
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-10-31 00:36:31 +00:00
29e32c56cc Merge pull request 'Update Rust crate clap to 4.5.51' (#109) from renovate/clap-4.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-10-30 00:37:49 +00:00
bde815b123 Update Rust crate clap to 4.5.51
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-10-30 00:37:41 +00:00
c428e51df0 Merge pull request 'Update Rust crate clap to 4.5.50' (#108) from renovate/clap-4.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-10-21 00:38:07 +00:00
1967b29b9f Update Rust crate clap to 4.5.50
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-10-21 00:38:00 +00:00
ed8ee5dc58 Merge pull request 'Update Rust crate rust-embed to 8.8.0' (#107) from renovate/rust-embed-8.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-10-19 00:37:24 +00:00
1c9ce3cc32 Update Rust crate rust-embed to 8.8.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-10-19 00:37:22 +00:00
f4e9fcf40f Merge pull request 'Update Rust crate tokio to 1.48.0' (#106) from renovate/tokio-1.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-10-15 00:36:34 +00:00
f522d0e700 Update Rust crate tokio to 1.48.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-10-15 00:36:28 +00:00
45213e74cb Merge pull request 'Update Rust crate clap to 4.5.49' (#105) from renovate/clap-4.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-10-14 00:38:07 +00:00
627647bf47 Update Rust crate clap to 4.5.49
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-10-14 00:37:58 +00:00
392b9fd5d6 Merge pull request 'Update docker.io/postgres Docker tag to v18' (#104) from renovate/docker.io-postgres-18.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-10-02 00:36:52 +00:00
1c47cbe0d8 Update docker.io/postgres Docker tag to v18
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-10-01 00:35:26 +00:00
becfea8e82 Merge pull request 'Update Rust crate thiserror to 2.0.17' (#103) from renovate/thiserror-2.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-09-30 00:35:04 +00:00
f6c9d7f4d6 Update Rust crate thiserror to 2.0.17
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2025-09-30 00:35:02 +00:00
34bca2b609 Merge pull request 'Update Rust crate serde to 1.0.228' (#102) from renovate/serde-1.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-09-28 00:35:09 +00:00
5cd681b8da Update Rust crate serde to 1.0.228
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2025-09-28 00:35:06 +00:00
55b253c9a6 Merge pull request 'Update Rust crate serde to 1.0.227' (#101) from renovate/serde-1.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-09-27 00:36:55 +00:00
504dabfe67 Update Rust crate serde to 1.0.227
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2025-09-27 00:36:54 +00:00
75b04a4998 Merge pull request 'Update Rust crate serde to 1.0.226' (#100) from renovate/serde-1.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-09-22 00:38:22 +00:00
e076c4b9fe Update Rust crate serde to 1.0.226
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-09-22 00:38:10 +00:00
ce5f8c6c70 Merge pull request 'Update Rust crate clap to 4.5.48' (#99) from renovate/clap-4.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-09-20 00:36:11 +00:00
20f28d2e6f Merge pull request 'Update Rust crate anyhow to 1.0.100' (#98) from renovate/anyhow-1.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-09-20 00:36:02 +00:00
7e853d4c89 Update Rust crate clap to 4.5.48
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-09-20 00:36:02 +00:00
e2c16d9450 Update Rust crate anyhow to 1.0.100
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2025-09-20 00:35:59 +00:00
ae1550248e Merge pull request 'Update Rust crate rust-s3 to 0.37.0' (#97) from renovate/rust-s3-0.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-09-17 00:35:28 +00:00
03e5c375fb Update Rust crate rust-s3 to 0.37.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-09-17 00:35:26 +00:00
3db32e24c7 Merge pull request 'Update Rust crate serde to 1.0.225' (#96) from renovate/serde-1.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-09-17 00:35:21 +00:00
f9fe44f53d Update Rust crate serde to 1.0.225
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2025-09-17 00:35:19 +00:00
659433911a Merge pull request 'Update Rust crate serde to 1.0.224' (#95) from renovate/serde-1.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-09-16 00:35:23 +00:00
27bb4d3382 Update Rust crate serde to 1.0.224
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2025-09-16 00:35:20 +00:00
b7c7b74122 Merge pull request 'Update Rust crate serde to 1.0.223' (#93) from renovate/serde-1.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-09-15 00:35:52 +00:00
731cf7327c Update Rust crate serde to 1.0.223
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2025-09-15 00:35:50 +00:00
34d228e7b8 Merge pull request 'Update Rust crate chrono to 0.4.42' (#92) from renovate/chrono-0.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-09-09 00:34:58 +00:00
20800668c9 Update Rust crate chrono to 0.4.42
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-09-09 00:34:55 +00:00
ea022a0cfe Merge pull request 'Update Rust crate sha2 to 0.11.0-rc.2' (#90) from renovate/sha2-0.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-09-05 00:21:45 +00:00
fa088077d5 Merge pull request 'Update Rust crate log to 0.4.28' (#89) from renovate/log-0.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-09-05 00:21:41 +00:00
e7e8016b67 Update Rust crate sha2 to 0.11.0-rc.2
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-09-05 00:21:41 +00:00
d88cd8cd8d Update Rust crate log to 0.4.28
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-09-05 00:21:39 +00:00
08c2009421 Merge pull request 'Update Rust crate ruma to 0.13.0' (#88) from renovate/ruma-0.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-09-04 00:28:51 +00:00
fddd9d011a Update Rust crate ruma to 0.13.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-09-04 00:28:43 +00:00
e8271834eb Merge pull request 'Update Rust crate clap to 4.5.47' (#87) from renovate/clap-4.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-09-04 00:28:33 +00:00
82a0a19b1c Update Rust crate clap to 4.5.47
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is failing
2025-09-03 00:27:35 +00:00
a5d2af3a8d Add alloc feature to create base16ct
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-28 08:13:02 +02:00
95ae3de818 Merge pull request 'Update Rust crate actix-session to 0.11.0' (#86) from renovate/actix-session-0.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-08-28 00:28:05 +00:00
ae79f16d17 Merge pull request 'Update Rust crate clap to 4.5.46' (#85) from renovate/clap-4.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-08-28 00:28:02 +00:00
57893e557c Update Rust crate actix-session to 0.11.0
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-08-27 00:27:40 +00:00
526a7dbb1f Update Rust crate clap to 4.5.46
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-08-27 00:27:38 +00:00
d0f297e354 Merge pull request 'Update Rust crate base16ct to 0.3.0' (#84) from renovate/base16ct-0.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-08-22 00:27:11 +00:00
ecb3fb7196 Update Rust crate base16ct to 0.3.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-08-22 00:27:07 +00:00
0fc067a6c5 Merge pull request 'Update Rust crate thiserror to 2.0.16' (#83) from renovate/thiserror-2.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-22 00:27:03 +00:00
213281f7b6 Update Rust crate thiserror to 2.0.16
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is failing
2025-08-21 00:27:38 +00:00
8ae1b7fb78 Merge pull request 'Update Rust crate serde_json to 1.0.143' (#82) from renovate/serde_json-1.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-21 00:27:33 +00:00
87fed55d8c Update Rust crate serde_json to 1.0.143
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-08-20 00:27:27 +00:00
7db8b8a1b9 Merge pull request 'Update Rust crate thiserror to 2.0.15' (#81) from renovate/thiserror-2.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-18 00:26:26 +00:00
299b05cf7b Update Rust crate thiserror to 2.0.15
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-08-17 00:27:30 +00:00
d3717dcb8d Merge pull request 'Update Rust crate uuid to 1.18.0' (#80) from renovate/uuid-1.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-16 00:26:25 +00:00
0b99f1e44b Update Rust crate uuid to 1.18.0
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-08-15 00:27:46 +00:00
d7de13a002 Merge pull request 'Update Rust crate thiserror to 2.0.14' (#79) from renovate/thiserror-2.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-15 00:27:40 +00:00
ed83ce4a10 Update Rust crate thiserror to 2.0.14
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-08-14 00:27:18 +00:00
22f6f2cca5 Merge pull request 'Update Rust crate clap to 4.5.45' (#78) from renovate/clap-4.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-14 00:27:12 +00:00
a8d28526df Update Rust crate clap to 4.5.45
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-08-13 00:27:40 +00:00
b24d214b3f Merge pull request 'Update Rust crate anyhow to 1.0.99' (#77) from renovate/anyhow-1.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-13 00:27:34 +00:00
a9947c155a Update Rust crate anyhow to 1.0.99
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-08-12 00:27:01 +00:00
bbdfc9affb Merge pull request 'Update Rust crate clap to 4.5.43' (#76) from renovate/clap-4.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-08 00:15:09 +00:00
a54f406371 Update Rust crate clap to 4.5.43
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-08-07 00:15:08 +00:00
afac1c9f93 Merge pull request 'Update Rust crate ruma to 0.12.6' (#75) from renovate/ruma-0.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-07 00:15:04 +00:00
1f0ca1823f Update Rust crate ruma to 0.12.6
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-08-06 00:14:49 +00:00
1534b11606 Merge pull request 'Update Rust crate tokio to 1.47.1' (#74) from renovate/tokio-1.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-03 00:16:09 +00:00
dc179184f7 Update Rust crate tokio to 1.47.1
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-08-02 00:16:43 +00:00
ecba5816d9 Merge pull request 'Update Rust crate serde_json to 1.0.142' (#73) from renovate/serde_json-1.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-02 00:16:37 +00:00
24e8804aed Update Rust crate serde_json to 1.0.142
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-08-01 00:19:23 +00:00
0e9880fec6 Merge pull request 'Update Rust crate clap to 4.5.42' (#72) from renovate/clap-4.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-01 00:19:17 +00:00
16b6d20b5d Update Rust crate clap to 4.5.42
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-07-31 00:14:21 +00:00
aaf49ec02b Merge pull request 'Update Rust crate tokio to 1.47.0' (#71) from renovate/tokio-1.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-27 00:15:52 +00:00
619a4d6ba5 Update Rust crate tokio to 1.47.0
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is passing
2025-07-27 00:15:51 +00:00
1ac1b0b13f Merge pull request 'Update Rust crate rand to 0.9.2' (#70) from renovate/rand-0.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-21 00:15:58 +00:00
176766623a Update Rust crate rand to 0.9.2
Some checks failed
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is failing
2025-07-21 00:15:55 +00:00
77d618a87d Merge pull request 'Update Rust crate serde_json to 1.0.141' (#69) from renovate/serde_json-1.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-19 00:16:12 +00:00
ff928609da Update Rust crate serde_json to 1.0.141
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is passing
2025-07-19 00:16:11 +00:00
54dd04b65e Merge pull request 'Update Rust crate clap to 4.5.41' (#68) from renovate/clap-4.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-10 00:52:29 +00:00
8ca288748d Update Rust crate clap to 4.5.41
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-07-10 00:16:08 +00:00
3849e812a8 Merge pull request 'Update Rust crate ruma to 0.12.5' (#67) from renovate/ruma-0.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-09 00:40:11 +00:00
57e49a86d9 Update Rust crate ruma to 0.12.5
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-07-09 00:15:36 +00:00
9cfe3f33e9 Merge pull request 'Update Rust crate ruma to 0.12.4' (#66) from renovate/ruma-0.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-08 00:40:43 +00:00
26d1ee3602 Update Rust crate ruma to 0.12.4
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-07-08 00:15:35 +00:00
286547d455 Merge pull request 'Update Rust crate tokio to 1.46.1' (#65) from renovate/tokio-1.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-05 00:44:48 +00:00
47337dc140 Update Rust crate tokio to 1.46.1
Some checks failed
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is failing
2025-07-05 00:09:10 +00:00
e1b0c9563f Update Rust crate tokio to 1.46.0
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-07-04 00:09:01 +00:00
5e84b40d05 Fix cargo clippy issues
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-03 08:32:53 +02:00
4d1587dda2 Update Rust crate clap to 4.5.40
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-06-10 00:22:12 +00:00
06202d5e9d Update Rust crate ruma to 0.12.3
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-06-03 00:22:14 +00:00
bc49b0e59e Update Rust crate sha2 to 0.11.0-rc.0
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-05-30 00:07:06 +00:00
233eb9c250 Update Rust crate clap to 4.5.39
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-05-28 00:07:58 +00:00
acf92592ff Update Rust crate tokio to 1.45.1
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-05-25 00:07:05 +00:00
da82b820fd Update Rust crate uuid to 1.17.0
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-05-24 00:07:16 +00:00
409e84951b Update Rust crate actix-web to 4.11.0
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-05-20 00:07:05 +00:00
f7527e6bc5 Update
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-19 21:41:49 +02:00
0106b2bfea Fix issue
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-19 21:39:29 +02:00
b6020b99c6 Fix cargo clippy issue
Some checks failed
continuous-integration/drone/push Build is failing
2025-05-19 21:37:05 +02:00
ed7a0688c3 Update Rust crate rust-embed to 8.7.2
Some checks failed
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is failing
2025-05-15 00:16:06 +00:00
9480f0dae9 Update Rust crate clap to 4.5.38
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-05-12 00:16:26 +00:00
b99eb4d178 Update Rust crate tokio to 1.45.0
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-05-07 00:24:32 +00:00
044a089f4a Update Rust crate rust-embed to 8.7.1
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-05-06 00:24:26 +00:00
f1a7943d93 Update Rust crate chrono to 0.4.41
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-04-30 00:19:34 +00:00
82c3c3a7b7 Update Rust crate askama to 0.14.0
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-04-24 00:19:29 +00:00
7bc21fbb68 Update Rust crate rust-embed to 8.7.0
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-04-20 00:27:04 +00:00
f67e680522 Update Rust crate clap to 4.5.37
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-04-19 00:27:36 +00:00
c296196933 Update Rust crate rand to 0.9.1
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-04-18 00:27:27 +00:00
3adc2f424a Update Rust crate uuid to 1.16.0
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-04-17 00:26:36 +00:00
479d4d76bd Update Rust crate askama to 0.13.1
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-04-16 00:26:49 +00:00
4b4897c1f4 Update Rust crate anyhow to 1.0.98
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-04-15 00:26:43 +00:00
443ba0f7d5 Update Rust crate tokio to 1.44.2
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-04-14 00:26:47 +00:00
86fc06e51f Update Rust crate thiserror to 2.0.12
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-04-13 00:26:58 +00:00
b81b7657cc Update Rust crate clap to 4.5.36
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-04-12 00:27:14 +00:00
e14c8af5c5 Update Rust crate sha2 to 0.11.0-pre.5
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-04-11 00:27:06 +00:00
44c59aadab Update Rust crate serde_json to 1.0.140
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-04-10 00:26:58 +00:00
ee0558233b Update Rust crate serde to 1.0.219
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-04-09 00:27:26 +00:00
c2d82cf9c4 Update Rust crate ruma to 0.12.2
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-04-08 00:27:18 +00:00
a75ff3fc64 Update Rust crate rust-embed to 8.6.0
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-04-07 00:33:15 +00:00
bf8122ae81 Update Rust crate ruma to 0.12.1
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-04-06 00:33:57 +00:00
04f4255aab Update Rust crate clap to 4.5.35
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-04-05 00:33:46 +00:00
f350ee4ca9 Update Rust crate log to 0.4.27
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-04-04 00:34:39 +00:00
fdb3187d69 Update Rust crate light-openid to 1.0.4
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-04-03 00:34:36 +00:00
ec5d2cc40f Update Rust crate env_logger to 0.11.8
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-04-02 00:34:41 +00:00
79efdc95bf Update Rust crate jwt-simple to 0.12.12
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-04-01 02:05:11 +00:00
fab873d920 Update Rust crate clap to 4.5.34
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-03-31 20:50:22 +00:00
33f181f20b Update Rust crate chrono to 0.4.40
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-03-31 00:34:33 +00:00
c23b6ca0b9 Update Rust crate bytes to 1.10.1
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-03-30 02:20:25 +00:00
55d70fc8d9 Update Rust crate anyhow to 1.0.97
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-03-29 22:56:33 +00:00
fe3c441e7b Update Rust crate actix-web to 4.10.2
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2025-03-29 18:14:37 +00:00
68ce10f3ad Update renovate.json
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-29 17:32:25 +00:00
e40c567254 Update renovate.json
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-29 12:57:20 +00:00
0c73bc7b51 Merge pull request 'Update Rust crate clap to v4.5.34' (#25) from renovate/clap-4.x-lockfile into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #25
2025-03-29 12:57:10 +00:00
e876258394 Update Rust crate clap to v4.5.34
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-03-29 00:31:36 +00:00
59de36eda5 Merge pull request 'Update Rust crate log to v0.4.27' (#22) from renovate/log-0.x-lockfile into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #22
2025-03-28 15:29:12 +00:00
8112608736 Merge pull request 'Update Rust crate clap to v4.5.33' (#23) from renovate/clap-4.x-lockfile into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #23
2025-03-28 15:29:07 +00:00
0d68c31e0e Merge pull request 'Update Rust crate askama to 0.13.0' (#24) from renovate/askama-0.x into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #24
2025-03-28 15:29:01 +00:00
f4d3af3a89 Update Rust crate askama to 0.13.0
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-03-28 00:23:56 +00:00
e124abf496 Update Rust crate clap to v4.5.33
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-03-27 00:24:12 +00:00
53dea2c8f8 Update Rust crate log to v0.4.27
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-03-25 00:24:16 +00:00
54a0a9c914 Merge pull request 'Update docker.io/postgres Docker tag to v17' (#19) from renovate/docker.io-postgres-17.x into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #19
2025-03-18 20:59:51 +00:00
9c25527e83 Merge pull request 'Update Rust crate tokio to v1.44.1' (#20) from renovate/tokio-1.x-lockfile into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #20
2025-03-18 20:59:04 +00:00
e1accbfa21 Merge pull request 'Update Rust crate uuid to v1.16.0' (#21) from renovate/uuid-1.x-lockfile into master
Reviewed-on: #21
2025-03-18 20:58:23 +00:00
25f31cf9fb Fix config comment
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-17 21:01:31 +01:00
2d8973aec5 Update Rust crate uuid to v1.16.0
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-03-15 00:05:48 +00:00
e01fe63cfd Update Rust crate tokio to v1.44.1
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-03-14 00:06:00 +00:00
44ce00c69b Update docker.io/postgres Docker tag to v17
Some checks failed
continuous-integration/drone/push Build encountered an error
continuous-integration/drone/pr Build encountered an error
2025-03-13 00:05:41 +00:00
b877ecd759 Update all dependencies
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-12 20:26:24 +01:00
6bf56f7489 Merge pull request 'Update Rust crate actix-web to v4.10.2' (#17) from renovate/actix-web-4.x-lockfile into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #17
2025-03-12 19:25:43 +00:00
a7edb1e163 Merge pull request 'Update Rust crate tokio to v1.44.0' (#18) from renovate/tokio-1.x-lockfile into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #18
2025-03-12 19:25:37 +00:00
d341f8f42d Update Rust crate tokio to v1.44.0
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-03-12 00:19:45 +00:00
0bd5bc18a6 Update Rust crate actix-web to v4.10.2
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-03-12 00:19:42 +00:00
e94d2f7681 Merge pull request 'Update Rust crate clap to v4.5.32' (#15) from renovate/clap-4.x-lockfile into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #15
2025-03-11 13:12:28 +00:00
72a3bb5d9f Merge pull request 'Update Rust crate serde to v1.0.219' (#16) from renovate/serde-1.x-lockfile into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #16
2025-03-11 13:12:17 +00:00
4ae19f315c Merge pull request 'Update Rust crate sha2 to v0.11.0-pre.5' (#11) from renovate/sha2-0.x-lockfile into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #11
2025-03-11 13:12:10 +00:00
fe3de01bbf Update Rust crate sha2 to v0.11.0-pre.5
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-03-11 00:19:40 +00:00
201d8d5b86 Update Rust crate serde to v1.0.219
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-03-11 00:19:34 +00:00
8f3ee31524 Update Rust crate clap to v4.5.32
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-03-11 00:19:30 +00:00
3ae504e18e Merge pull request 'Update Rust crate jwt-simple to v0.12.12' (#6) from renovate/jwt-simple-0.x-lockfile into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #6
2025-03-10 18:00:30 +00:00
9b6f0c59d9 Merge pull request 'Update Rust crate light-openid to v1.0.3' (#7) from renovate/light-openid-1.x-lockfile into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #7
2025-03-10 18:00:19 +00:00
25a52bfcb2 Merge pull request 'Update Rust crate clap to v4.5.31' (#5) from renovate/clap-4.x-lockfile into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #5
2025-03-10 18:00:11 +00:00
d9a5bf6781 Merge pull request 'Update Rust crate serde to v1.0.218' (#9) from renovate/serde-1.x-lockfile into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #9
2025-03-10 18:00:02 +00:00
77e978c0f0 Merge pull request 'Update Rust crate serde_json to v1.0.140' (#10) from renovate/serde_json-1.x-lockfile into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #10
2025-03-10 17:59:54 +00:00
0db3a85a52 Merge pull request 'Update Rust crate log to v0.4.26' (#8) from renovate/log-0.x-lockfile into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #8
2025-03-10 17:59:36 +00:00
72f8c8c829 Merge pull request 'Update Rust crate thiserror to v2.0.12' (#12) from renovate/thiserror-2.x-lockfile into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #12
2025-03-10 17:59:17 +00:00
65df24578d Merge pull request 'Update Rust crate rust-embed to v8.6.0' (#14) from renovate/rust-embed-8.x-lockfile into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #14
2025-03-10 17:59:09 +00:00
7086ad823e Merge pull request 'Update Rust crate bytes to v1.10.1' (#13) from renovate/bytes-1.x-lockfile into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #13
2025-03-10 17:58:57 +00:00
aac8a04182 Update Rust crate rust-embed to v8.6.0
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-03-10 00:19:46 +00:00
faf0d3acee Update Rust crate bytes to v1.10.1
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-03-10 00:19:41 +00:00
ecec54723e Update Rust crate thiserror to v2.0.12
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-03-09 00:18:57 +00:00
27d56c3c8f Update Rust crate serde_json to v1.0.140
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-03-08 00:19:10 +00:00
896ea03a36 Update Rust crate serde to v1.0.218
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-03-08 00:19:02 +00:00
bfd24395d8 Update Rust crate log to v0.4.26
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-03-07 00:19:17 +00:00
ab96bc6a00 Update Rust crate light-openid to v1.0.3
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-03-07 00:19:13 +00:00
282cf44f58 Update Rust crate jwt-simple to v0.12.12
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-03-06 00:19:02 +00:00
e5273d162c Update Rust crate clap to v4.5.31
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-03-06 00:18:54 +00:00
5ce066cdea Merge pull request 'Update Rust crate anyhow to v1.0.97' (#3) from renovate/anyhow-1.x-lockfile into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #3
2025-03-05 20:40:02 +00:00
4a55c84c8c Merge pull request 'Update Rust crate chrono to v0.4.40' (#4) from renovate/chrono-0.x-lockfile into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #4
2025-03-05 20:39:55 +00:00
244d36830d Update Rust crate chrono to v0.4.40
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-03-05 00:19:07 +00:00
2f161841eb Update Rust crate anyhow to v1.0.97
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-03-05 00:19:03 +00:00
064aa92cb4 Fix example
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-04 19:37:33 +01:00
0b16fd2202 Release v1.0.0
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/tag Build is failing
2025-03-04 19:32:56 +01:00
bab8edbe7f Add new info in README 2025-02-27 20:51:06 +01:00
ff43364df3 Can get the list of joined rooms by user 2025-02-27 20:47:57 +01:00
95ca1f0ba6 Can get media thumbnail 2025-02-27 20:33:25 +01:00
d68e3eca3b Simplify route to get room information 2025-02-27 20:18:18 +01:00
3640f72d73 Can get user information 2025-02-27 20:08:13 +01:00
6adc0c1fbb Parse Matrix media URL for clients 2025-02-26 21:15:07 +01:00
fa4665280d Can get user media 2025-02-26 21:06:00 +01:00
4589b3b339 Can get the name of a room through the API 2025-02-26 20:47:32 +01:00
53186d2e24 Element messages to back to websocket 2025-02-25 19:52:42 +01:00
b91b1ba096 Start to synchronize with Matrix 2025-02-25 19:29:33 +01:00
4ee26df97b Start sync thread implementation 2025-02-25 19:02:31 +01:00
3822c209d3 Start sync client manager implementation 2025-02-12 21:01:34 +01:00
17e086b43c Ready to implement sync client manager 2025-02-11 22:20:23 +01:00
bb1e412d36 Implement disconnect specific client ID 2025-02-11 22:03:16 +01:00
b8a8e14f3c Add WS debug console 2025-02-11 21:57:34 +01:00
558d5cda3f Can close all user sessions when Matrix token is changed 2025-02-06 22:39:53 +01:00
4ff72e073e Add basic ping-pong websocket 2025-02-06 21:40:11 +01:00
c573d2f74a No longer use randomly generated key to encrypt session cookie 2025-02-04 21:12:29 +01:00
babb3a2e07 Can use cookie to authenticate to API 2025-02-04 21:08:56 +01:00
147 changed files with 17780 additions and 14619 deletions

View File

@@ -1,33 +0,0 @@
[package]
name = "matrix_gateway"
version = "0.1.0"
edition = "2021"
[dependencies]
log = "0.4.25"
env_logger = "0.11.6"
clap = { version = "4.5.26", features = ["derive", "env"] }
lazy_static = "1.5.0"
anyhow = "1.0.95"
serde = { version = "1.0.217", features = ["derive"] }
serde_json = "1.0.137"
rust-s3 = { version = "0.36.0-beta.2", features = ["tokio"] }
actix-web = "4"
actix-session = { version = "0.10.1", features = ["redis-session"] }
light-openid = "1.0.2"
thiserror = "2.0.11"
rand = "0.9.0"
rust-embed = "8.5.0"
mime_guess = "2.0.5"
askama = "0.12.1"
urlencoding = "2.1.3"
uuid = { version = "1.12.1", features = ["v4", "serde"] }
ipnet = { version = "2.11.0", features = ["serde"] }
chrono = "0.4.39"
futures-util = "0.3.31"
jwt-simple = { version = "0.12.11", default-features=false, features=["pure-rust"] }
actix-remote-ip = "0.1.0"
bytes = "1.9.0"
sha2 = "0.11.0-pre.4"
base16ct = "0.2.0"
ruma = { version = "0.12.0", features = ["client-api-c", "client-ext-client-api", "client-hyper-native-tls", "rand"] }

674
LICENSE Normal file
View File

@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

View File

@@ -1,22 +1,58 @@
# Matrix Gateway
WIP project
[![Build Status](https://drone.communiquons.org/api/badges/pierre/MatrixGW/status.svg)](https://drone.communiquons.org/pierre/MatrixGW)
Project that expose a simple API to make use of Matrix API. It acts as a Matrix client (like Element for example)
**Known limitations**:
- Supports only a limited subset of Matrix API
- Does not support spaces
Project written in Rust and TypeScript. Releases are published on Docker Hub.
## Docker image options
```bash
docker run --rm -it docker.io/pierre42100/matrix_gateway --help
```
## Setup dev environment
### Dependencies
```
mkdir -p storage/postgres storage/synapse storage/minio
sudo apt install -y libsqlite3-dev
cd matrixgw_backend
mkdir -p storage/maspostgres storage/synapse
docker compose up
```
To create default account, in another terminal, run the following command:
```bash
docker compose --profile create-accounts up -d
```
URLs:
* Element: http://localhost:8080/
* Synapse: http://localhost:8448/
* Matrix Authentication Service: http://localhost:8778/
* OpenID configuration: http://127.0.0.1:9001/dex/.well-known/openid-configuration
* Minio console: http://localhost:9002/
Auto-created Matrix accounts:
* `admin1` : `admin1`
* `user1` : `user1`
Minio administration credentials: `minioadmin` : `minioadmin`
### Backend
```bash
cd matrixgw_backend
cargo fmt && cargo clippy && cargo run --
```
### Frontend
```bash
cd matrixgw_frontend
npm install
npm run dev
```

12199
assets/bootstrap.css vendored

File diff suppressed because it is too large Load Diff

View File

@@ -1,30 +0,0 @@
/**
* Delete a client referenced by its ID
*
* @param clientID The ID of the client to delete
*/
async function deleteClient(clientID) {
if(!confirm("Do you really want to remove client " + clientID + "? The operation cannot be reverted!"))
return;
try {
const res = await fetch("/", {
method: "POST",
headers:{
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
"delete_client_id": clientID
}),
});
if(res.status !== 200)
throw new Error(`Invalid status code: ${res.status}`);
alert("The client was successfully deleted!");
location.reload();
} catch (e) {
console.error(`Failed to delete client: ${e}`);
alert("Failed to delete client!");
}
}

View File

@@ -1,12 +0,0 @@
.body-content {
max-width: 900px;
margin: 50px auto;
}
.body-content .card-header {
font-weight: bold;
}
#user_id_container {
margin: 20px auto;
}

View File

@@ -1,2 +0,0 @@
register_new_matrix_user -a --user admin1 --password admin1 --config /config/homeserver.yaml;
register_new_matrix_user --no-admin --user user1 --password user1 --config /config/homeserver.yaml;

View File

@@ -1,3 +1,4 @@
storage
app_storage
.idea
target

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,36 @@
[package]
name = "matrixgw_backend"
version = "0.1.0"
edition = "2024"
[dependencies]
env_logger = "0.11.8"
log = "0.4.28"
clap = { version = "4.5.51", features = ["derive", "env"] }
lazy_static = "1.5.0"
anyhow = "1.0.100"
serde = { version = "1.0.228", features = ["derive"] }
tokio = { version = "1.48.0", features = ["full"] }
actix-web = "4.11.0"
actix-session = { version = "0.11.0", features = ["redis-session"] }
actix-remote-ip = "0.1.0"
actix-cors = "0.7.1"
light-openid = "1.0.4"
bytes = "1.10.1"
sha2 = "0.10.9"
base16ct = { version = "0.3.0", features = ["alloc"] }
futures-util = "0.3.31"
jwt-simple = { version = "0.12.13", default-features = false, features = ["pure-rust"] }
thiserror = "2.0.17"
uuid = { version = "1.18.1", features = ["v4", "serde"] }
ipnet = { version = "2.11.0", features = ["serde"] }
rand = "0.9.2"
hex = "0.4.3"
mailchecker = "6.0.19"
matrix-sdk = { version = "0.14.0" }
url = "2.5.7"
ractor = "0.15.9"
serde_json = "1.0.145"
lazy-regex = "3.4.2"
actix-ws = "0.3.0"
infer = "0.19.0"

View File

@@ -0,0 +1,10 @@
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y libssl3 \
&& rm -rf /var/lib/apt/lists/*
COPY matrix_gateway /usr/local/bin/matrix_gateway
ENTRYPOINT ["/usr/local/bin/matrix_gateway"]

View File

@@ -0,0 +1,2 @@
# Matrix Gateway backend
Backend component, written in Rust using Actix.

View File

@@ -1,15 +1,48 @@
services:
mas:
image: ghcr.io/element-hq/matrix-authentication-service:main
user: "1000"
restart: unless-stopped
depends_on:
- masdb
volumes:
- ./docker/mas:/config:ro
command: server -c /config/config.yaml
ports:
- "8778:8778/tcp"
mas_create_admin1:
image: ghcr.io/element-hq/matrix-authentication-service:main
user: "1000"
restart: no
profiles: ["create-accounts"]
depends_on:
- mas
volumes:
- ./docker/mas:/config:ro
command: |
manage register-user -c /config/config.yaml -y --ignore-password-complexity
-p admin1 -e admin1@admin1.local --admin -d "Admin One" admin1
mas_create_user1:
image: ghcr.io/element-hq/matrix-authentication-service:main
user: "1000"
restart: no
profiles: ["create-accounts"]
depends_on:
- mas
volumes:
- ./docker/mas:/config:ro
command: |
manage register-user -c /config/config.yaml -y --ignore-password-complexity
-p user1 -e user1@user1.local -d "User One" user1
synapse:
image: docker.io/matrixdotorg/synapse:latest
user: "1000"
# Since synapse does not retry to connect to the database, restart upon
# failure
restart: unless-stopped
entrypoint: /bin/bash
command: >
-c "nohup bash -c 'sleep 10; /config/delayed_accounts_creation.sh' \&
./start.py"
# See the readme for a full documentation of the environment settings
# NOTE: You must edit homeserver.yaml to use postgres, it defaults to sqlite
environment:
@@ -22,61 +55,48 @@ services:
# - ./files:/data
# - /path/to/ssd:/data/uploads
# - /path/to/large_hdd:/data/media
depends_on:
- db
# In order to expose Synapse, remove one of the following, you might for
# instance expose the TLS port directly:
ports:
- 8448:8448/tcp
- "8448:8448/tcp"
db:
image: docker.io/postgres:12-alpine
masdb:
image: docker.io/postgres:18-alpine
user: "1000"
environment:
- POSTGRES_USER=synapse
- POSTGRES_DB=masdb
- POSTGRES_USER=masdb
- POSTGRES_PASSWORD=changeme
# ensure the database gets created correctly
# https://element-hq.github.io/synapse/latest/postgres.html#set-up-database
- POSTGRES_INITDB_ARGS=--encoding=UTF-8 --lc-collate=C --lc-ctype=C
- PGDATA=/data
volumes:
# You may store the database tables in a local folder..
- ./storage/postgres:/var/lib/postgresql/data
- ./storage/maspostgres:/data
# .. or store them on some high performance storage for better results
# - /path/to/ssd/storage:/var/lib/postgresql/data
element:
image: docker.io/vectorim/element-web
ports:
- 8080:80/tcp
- "8080:80/tcp"
volumes:
- ./docker/element/config.json:/app/config.json:ro
oidc:
image: dexidp/dex
ports:
- 9001:9001
- "9001:9001"
volumes:
- ./docker/dex:/conf:ro
command: ["dex", "serve", "/conf/dex.config.yaml"]
minio:
image: quay.io/minio/minio
command: minio server --console-address ":9002" /data
ports:
- 9000:9000/tcp
- 9002:9002/tcp
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
volumes:
# You may store the database tables in a local folder..
- ./storage/minio:/data
command: [ "dex", "serve", "/conf/dex.config.yaml" ]
redis:
image: redis:alpine
command: redis-server --requirepass ${REDIS_PASS:-secretredis}
ports:
- 6379:6379
- "6379:6379"
volumes:
- ./storage/redis-data:/data
- ./storage/redis-conf:/usr/local/etc/redis/redis.conf

View File

@@ -22,5 +22,5 @@ staticClients:
- id: foo
secret: bar
redirectURIs:
- http://localhost:8000/oidc_cb
- http://localhost:5173/oidc_cb
name: Project

View File

@@ -0,0 +1,113 @@
http:
listeners:
- name: web
resources:
- name: discovery
- name: human
- name: oauth
- name: compat
- name: graphql
- name: assets
binds:
- address: '[::]:8778'
proxy_protocol: false
- name: internal
resources:
- name: health
binds:
- host: localhost
port: 8081
proxy_protocol: false
trusted_proxies:
- 192.168.0.0/16
- 172.16.0.0/12
- 10.0.0.0/10
- 127.0.0.1/8
- fd00::/8
- ::1/128
public_base: http://localhost:8778/
issuer: http://localhost:8778/
database:
uri: postgresql://masdb:changeme@masdb/masdb
max_connections: 10
min_connections: 0
connect_timeout: 30
idle_timeout: 600
max_lifetime: 1800
email:
from: '"Authentication Service" <root@localhost>'
reply_to: '"Authentication Service" <root@localhost>'
transport: blackhole
secrets:
encryption: 12de9ad7bc2bacfa2ab9b1e3f7f1b3feb802195c8ebe66a8293cdb27f00be471
keys:
- kid: Bj2PICQ7mf
key: |
-----BEGIN RSA PRIVATE KEY-----
MIIEogIBAAKCAQEAsCYCrrCJA7IuGbTYzP5yZN74QszbzudBUCX6MyN/+36HO2r6
xL8x1PRJ+Klx9Y90J9pWuo+cIuEmFLqO+Yfblo9fSQgZVvkWAFpO6Xh8J4z9qg49
M8xm0Ct8EnRDZDCEOBnwoDaAB9RTbpJGa1RPVCiamfi+xU+j47Zl4Er5jvLm81O7
DSlH9eK8Eih8AxuKTkAbKE1zyXquImE26Mj2dmMRfjDrWV/I8oqE3WFViAKR12Av
zw6TUyduiz8nK9pONCF3NIcQvBdHntBz1HlDXv6i0fRvlGIhjNL5LBgo6XQ3rNM1
bW2KYOw/iFP0YbfD4/xRjkBPvK2coQ8aRzK2VwIDAQABAoH/G4XU5Xav8ePlUB7x
wRYAycINCGL59Vos2lkUvujNFn6uopoUlKlLH/sLk87l/3hqrc9vvbayrsB/Mr3z
mQmhReUg/khFrVE+Hs/9hH1O6N8ew3N2HKHTbrNcr4V7AiySfDGRZ3ccihyi7KPu
XNbPjlbJ0UUMicfn06ysPl94nt0So0UAmXg+c7sDDqyzh3cY8emedYZ5FCljo/jA
F8k40rs7CywLJYMJB9O1vtomgt1xkDRO4F8UrZrriMIcYn0iFKe7i4AH8D6nkgNu
/v9Z43Leu8yRKrUvbpH3NaX8DlUSFWAXKpwUWr4sAQgWcLkVgjAXG1v9jCE97qW2
f0nBAoGBAOaKrnY5rWeZ74dERnPhSCsYiqRMneQAh7eJR+Er+xu1yF/bxwkhq2tK
/txheTK448DqhQRtr095t/v7TMZcPl3bSmybT1CQg/wiMJsgDMZqlC9tofvcq6uz
xP8vxMFHd0YSMSP693dkny4MzNY6LuoVWDLT+HxKPJyzGs1alruzAoGBAMOZp5J2
3ODcHQlcsGBtj1yVpQ4UXMvrSZF2ygiGK9bagL/f1iAtwACVOh5rgmbiOLSVgmR2
n4nupTgSAXMYkjmAmDyEh0PDaRl4WWvYEKp8GMvTPVPvjc6N0dT+y8Mf9bu+LcEt
+uZqPOZNbO5Vi+UgGeM9zZpxq/K7dpJmM/jNAoGBALsYHRGxKTsEwFEkZZCxaWIg
HpPL4e8hRwL6FC13BeitFBpHQDX27yi5yi+Lo1I4ngz3xk+bvERhYaDLhrkML0j4
KGQPfsTBI3vBO3UJA5Ua9XuwG19M7L0BvYPjfmfk2bUyGlM63w4zyMMUfD/3JA+w
ls1ZHTWxAZOh/sRdGirlAoGAX16B1+XgmDp6ZeAtlzaUGd5U1eKTxFF6U1SJ+VIB
+gYblHI84v+riB06cy6ULDnM0C+9neJAs24KXKZa0pV+Zk8O6yLrGN0kV2jYoL5+
kcFkDa13T3+TssxvLNz22LKyi9GUWYZjuQi/nMLPg/1t8k+Oj7/Iia822WkRzRvL
51kCgYEAwrN5Us8LR+fThm3C0vhvwv2wap6ccw0qq5+FTN+igAZAmmvKKvhow2Vi
LnPKBkc7QvxvQSNoXkdUo4qs3zOQ7DGvJLqSG9pwxFW5X1+78pNEm5OWe8AlT1uZ
Jz8Z1/Ae7fr/fFaucW9LkWjcuoPwPLiZ3b7ZQ6phs8qzoL+FpBI=
-----END RSA PRIVATE KEY-----
- kid: HcRvLHat12
key: |
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIOCCFSnkfz1ksln6kus8enQstBTu0q62IGJVzuX0WiXPoAoGCCqGSM49
AwEHoUQDQgAEVWPLbvSdxquLAjU3zJLcCWdaxr6QK1tPVbV1IS+87QUMv/zKiCMa
fNpwgBXwU7dF0gY507R2yY9pcdTmRtnRug==
-----END EC PRIVATE KEY-----
- kid: YjMITk5VSn
key: |
-----BEGIN EC PRIVATE KEY-----
MIGkAgEBBDCoPSjaN7qqnPz+vdzHeIy8RZCCtFOqLTkvylM1gz6xOGaVsS63VJw9
Td9BtpolZ0egBwYFK4EEACKhZANiAAT8tH88HYBHNiQTSqZzlxElSuSDC0+Xn0O9
ukj0xTTVBp8rUM9lCJQAlB8PjS2XK/n0YvYdzysQb3AYqszJa45/rOGvSar30YNE
gwpJvu36xNIKZT+nHalNwg069FdjNBc=
-----END EC PRIVATE KEY-----
- kid: NvFzzeMRU3
key: |
-----BEGIN EC PRIVATE KEY-----
MHQCAQEEILJEmFPDGFZoBVBQf1P6h4YfasYsFiu8a6FrFxiJvKXPoAcGBSuBBAAK
oUQDQgAE4NY5H3+D8r9GNOhrpbUn2dvLZIzi4A+SiwfqvtvPEmZkW+KDbd2tzKmx
maydZBn52QWedVY65snGAEoh9mV1TQ==
-----END EC PRIVATE KEY-----
passwords:
enabled: true
schemes:
- version: 1
algorithm: argon2id
minimum_complexity: 0
account:
password_registration_enabled: true
password_registration_email_required: false
matrix:
kind: synapse
homeserver: localhost
secret: IhKoLn6jWf1qRRZWvqgaKuIdwD6H0Mvx
endpoint: http://synapse:8448/
policy:
data:
client_registration:
allow_insecure_uris: true

View File

@@ -33,3 +33,9 @@ signing_key_path: "/config/localhost.signing.key"
trusted_key_servers:
- server_name: "matrix.org"
# vim:ft=yaml
matrix_authentication_service:
enabled: true
endpoint: http://mas:8778/
secret: "IhKoLn6jWf1qRRZWvqgaKuIdwD6H0Mvx"
# Alternatively, using a file:
#secret_file: /path/to/secret.txt

View File

@@ -1,11 +1,14 @@
use clap::Parser;
use jwt_simple::algorithms::HS256Key;
use jwt_simple::prelude::{Clock, Duration, JWTClaims, MACLike};
use matrix_gateway::extractors::client_auth::TokenClaims;
use matrix_gateway::utils::rand_str;
use matrixgw_backend::constants;
use matrixgw_backend::extractors::auth_extractor::{MatrixJWTKID, TokenClaims};
use matrixgw_backend::users::{APITokenID, UserEmail};
use matrixgw_backend::utils::rand_utils::rand_string;
use std::ops::Add;
use std::os::unix::prelude::CommandExt;
use std::process::Command;
use std::str::FromStr;
/// cURL wrapper to query MatrixGW
#[derive(Parser, Debug)]
@@ -19,9 +22,9 @@ struct Args {
#[arg(short('i'), long, env)]
token_id: String,
/// User ID
/// User email
#[arg(short('u'), long, env)]
user_id: String,
user_mail: String,
/// Token secret
#[arg(short('t'), long, env)]
@@ -59,7 +62,7 @@ fn main() {
subject: None,
audiences: None,
jwt_id: None,
nonce: Some(rand_str(10)),
nonce: Some(rand_string(10)),
custom: TokenClaims {
method: args.method.to_string(),
uri: args.uri,
@@ -68,17 +71,20 @@ fn main() {
};
let jwt = key
.with_key_id(&format!(
"{}#{}",
urlencoding::encode(&args.user_id),
urlencoding::encode(&args.token_id)
))
.with_key_id(
&MatrixJWTKID {
user_email: UserEmail(args.user_mail),
id: APITokenID::from_str(args.token_id.as_str())
.expect("Failed to decode token ID!"),
}
.to_string(),
)
.authenticate(claims)
.expect("Failed to sign JWT!");
let _ = Command::new("curl")
.args(["-X", &args.method])
.args(["-H", &format!("x-client-auth: {jwt}")])
.args(["-H", &format!("{}: {jwt}", constants::API_AUTH_HEADER)])
.args(args.run)
.arg(full_url)
.exec();

View File

@@ -0,0 +1,248 @@
use crate::users::{APITokenID, UserEmail};
use crate::utils::crypt_utils::sha256str;
use clap::Parser;
use matrix_sdk::authentication::oauth::registration::{
ApplicationType, ClientMetadata, Localized, OAuthGrantType,
};
use std::path::{Path, PathBuf};
use url::Url;
/// Matrix gateway backend API
#[derive(Parser, Debug, Clone)]
#[clap(author, version, about, long_about = None)]
pub struct AppConfig {
/// Listen address
#[clap(short, long, env, default_value = "0.0.0.0:8000")]
pub listen_address: String,
/// Website origin
#[clap(short, long, env, default_value = "http://localhost:5173")]
pub website_origin: String,
/// Proxy IP, might end with a star "*"
#[clap(short, long, env)]
pub proxy_ip: Option<String>,
/// Unsecure : for development, bypass authentication, using the account with the given
/// email address by default
#[clap(long, env)]
unsecure_auto_login_email: Option<String>,
/// Secret key, used to secure some resources. Must be randomly generated
#[clap(short = 'S', long, env, default_value = "")]
secret: String,
/// Matrix homeserver origin
#[clap(short, long, env, default_value = "http://127.0.0.1:8448")]
pub matrix_homeserver: String,
/// Redis connection hostname
#[clap(long, env, default_value = "localhost")]
redis_hostname: String,
/// Redis connection port
#[clap(long, env, default_value_t = 6379)]
redis_port: u16,
/// Redis database number
#[clap(long, env, default_value_t = 0)]
redis_db_number: i64,
/// Redis username
#[clap(long, env)]
redis_username: Option<String>,
/// Redis password
#[clap(long, env, default_value = "secretredis")]
redis_password: String,
/// URL where the OpenID configuration can be found
#[arg(
long,
env,
default_value = "http://localhost:9001/dex/.well-known/openid-configuration"
)]
pub oidc_configuration_url: String,
/// OpenID provider name
#[arg(long, env, default_value = "3rd party provider")]
pub oidc_provider_name: String,
/// OpenID client ID
#[arg(long, env, default_value = "foo")]
pub oidc_client_id: String,
/// OpenID client secret
#[arg(long, env, default_value = "bar")]
pub oidc_client_secret: String,
/// OpenID login redirect URL
#[arg(long, env, default_value = "APP_ORIGIN/oidc_cb")]
oidc_redirect_url: String,
/// Matrix oauth redirect URL
#[arg(long, env, default_value = "APP_ORIGIN/matrix_auth_cb")]
matrix_oauth_redirect_url: String,
/// Application storage path
#[arg(long, env, default_value = "app_storage")]
storage_path: String,
}
lazy_static::lazy_static! {
static ref ARGS: AppConfig = {
AppConfig::parse()
};
}
impl AppConfig {
/// Get parsed command line arguments
pub fn get() -> &'static AppConfig {
&ARGS
}
/// Get auto login email (if not empty)
pub fn unsecure_auto_login_email(&self) -> Option<UserEmail> {
match self.unsecure_auto_login_email.as_deref() {
None | Some("") => None,
Some(s) => Some(UserEmail(s.to_owned())),
}
}
/// Get app secret
pub fn secret(&self) -> &str {
let mut secret = self.secret.as_str();
if cfg!(debug_assertions) && secret.is_empty() {
secret = "DEBUGKEYDEBUGKEYDEBUGKEYDEBUGKEYDEBUGKEYDEBUGKEYDEBUGKEYDEBUGKEY";
}
if secret.is_empty() {
panic!("SECRET is undefined or too short (min 64 chars)!")
}
secret
}
/// Check if auth is disabled
pub fn is_auth_disabled(&self) -> bool {
self.unsecure_auto_login_email().is_some()
}
/// Get Redis connection configuration
pub fn redis_connection_string(&self) -> String {
format!(
"redis://{}:{}@{}:{}/{}",
self.redis_username.as_deref().unwrap_or(""),
self.redis_password,
self.redis_hostname,
self.redis_port,
self.redis_db_number
)
}
/// Get OpenID providers configuration
pub fn openid_provider(&self) -> OIDCProvider<'_> {
OIDCProvider {
client_id: self.oidc_client_id.as_str(),
client_secret: self.oidc_client_secret.as_str(),
configuration_url: self.oidc_configuration_url.as_str(),
name: self.oidc_provider_name.as_str(),
redirect_url: self
.oidc_redirect_url
.replace("APP_ORIGIN", &self.website_origin),
}
}
/// Matrix OAuth redirect URL
pub fn matrix_oauth_redirect_url(&self) -> String {
self.matrix_oauth_redirect_url
.replace("APP_ORIGIN", &self.website_origin)
}
/// Get Matrix client metadata information
pub fn matrix_client_metadata(&self) -> ClientMetadata {
let client_uri = Localized::new(
Url::parse(&self.website_origin).expect("Invalid website origin!"),
[],
);
ClientMetadata {
application_type: ApplicationType::Native,
grant_types: vec![OAuthGrantType::AuthorizationCode {
redirect_uris: vec![
Url::parse(&self.matrix_oauth_redirect_url())
.expect("Failed to parse matrix auth redirect URI!"),
],
}],
client_name: Some(Localized::new("MatrixGW".to_string(), [])),
logo_uri: Some(Localized::new(
Url::parse(&format!("{}/favicon.png", self.website_origin))
.expect("Invalid website origin!"),
[],
)),
policy_uri: Some(client_uri.clone()),
tos_uri: Some(client_uri.clone()),
client_uri,
}
}
/// Get storage path
pub fn storage_path(&self) -> &Path {
Path::new(self.storage_path.as_str())
}
/// User storage directory
pub fn user_directory(&self, mail: &UserEmail) -> PathBuf {
self.storage_path().join("users").join(sha256str(&mail.0))
}
/// User metadata file
pub fn user_metadata_file_path(&self, mail: &UserEmail) -> PathBuf {
self.user_directory(mail).join("metadata.json")
}
/// User API tokens directory
pub fn user_api_token_directory(&self, mail: &UserEmail) -> PathBuf {
self.user_directory(mail).join("api-tokens")
}
/// User API token metadata file
pub fn user_api_token_metadata_file(&self, mail: &UserEmail, id: &APITokenID) -> PathBuf {
self.user_api_token_directory(mail).join(id.0.to_string())
}
/// Get user Matrix database path
pub fn user_matrix_db_path(&self, mail: &UserEmail) -> PathBuf {
self.user_directory(mail).join("matrix-db")
}
/// Get user Matrix database passphrase path
pub fn user_matrix_passphrase_path(&self, mail: &UserEmail) -> PathBuf {
self.user_directory(mail).join("matrix-db-passphrase")
}
/// Get user Matrix session file path
pub fn user_matrix_session_file_path(&self, mail: &UserEmail) -> PathBuf {
self.user_directory(mail).join("matrix-session.json")
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct OIDCProvider<'a> {
pub name: &'a str,
pub client_id: &'a str,
pub client_secret: &'a str,
pub configuration_url: &'a str,
pub redirect_url: String,
}
#[cfg(test)]
mod test {
use crate::app_config::AppConfig;
#[test]
fn verify_cli() {
use clap::CommandFactory;
AppConfig::command().debug_assert()
}
}

View File

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

View File

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

View File

@@ -0,0 +1,137 @@
use crate::app_config::AppConfig;
use crate::broadcast_messages::BroadcastSender;
use crate::controllers::{HttpFailure, HttpResult};
use crate::extractors::auth_extractor::{AuthExtractor, AuthenticatedMethod};
use crate::extractors::matrix_client_extractor::MatrixClientExtractor;
use crate::extractors::session_extractor::MatrixGWSession;
use crate::users::{User, UserEmail};
use actix_remote_ip::RemoteIP;
use actix_web::{HttpResponse, web};
use light_openid::primitives::OpenIDConfig;
#[derive(serde::Serialize)]
struct StartOIDCResponse {
url: String,
}
/// Start OIDC authentication
pub async fn start_oidc(session: MatrixGWSession, remote_ip: RemoteIP) -> HttpResult {
let prov = AppConfig::get().openid_provider();
let conf = match OpenIDConfig::load_from_url(prov.configuration_url).await {
Ok(c) => c,
Err(e) => {
log::error!("Failed to fetch OpenID provider configuration! {e}");
return Ok(HttpResponse::InternalServerError()
.json("Failed to fetch OpenID provider configuration!"));
}
};
let state = match session.gen_oidc_state(remote_ip.0) {
Ok(s) => s,
Err(e) => {
log::error!("Failed to generate auth state! {e}");
return Ok(HttpResponse::InternalServerError().json("Failed to generate auth state!"));
}
};
Ok(HttpResponse::Ok().json(StartOIDCResponse {
url: conf.gen_authorization_url(
prov.client_id,
&state,
&AppConfig::get().openid_provider().redirect_url,
),
}))
}
#[derive(serde::Deserialize)]
pub struct FinishOpenIDLoginQuery {
code: String,
state: String,
}
/// Finish OIDC authentication
pub async fn finish_oidc(
session: MatrixGWSession,
remote_ip: RemoteIP,
req: web::Json<FinishOpenIDLoginQuery>,
) -> HttpResult {
if let Err(e) = session.validate_state(&req.state, remote_ip.0) {
log::error!("Failed to validate OIDC CB state! {e}");
return Ok(HttpResponse::BadRequest().json("Invalid state!"));
}
let prov = AppConfig::get().openid_provider();
let conf = OpenIDConfig::load_from_url(prov.configuration_url)
.await
.map_err(HttpFailure::OpenID)?;
let (token, _) = conf
.request_token(
prov.client_id,
prov.client_secret,
&req.code,
&AppConfig::get().openid_provider().redirect_url,
)
.await
.map_err(HttpFailure::OpenID)?;
let (user_info, _) = conf
.request_user_info(&token)
.await
.map_err(HttpFailure::OpenID)?;
if user_info.email_verified != Some(true) {
log::error!("Email is not verified!");
return Ok(HttpResponse::Unauthorized().json("Email unverified by IDP!"));
}
let mail = match user_info.email {
Some(m) => m,
None => {
return Ok(HttpResponse::Unauthorized().json("Email not provided by the IDP!"));
}
};
let user_name = user_info.name.unwrap_or_else(|| {
format!(
"{} {}",
user_info.given_name.as_deref().unwrap_or(""),
user_info.family_name.as_deref().unwrap_or("")
)
});
let user = User::create_or_update_user(&UserEmail(mail), &user_name).await?;
session.set_user(&user)?;
Ok(HttpResponse::Ok().finish())
}
/// Get current user information
pub async fn auth_info(client: MatrixClientExtractor) -> HttpResult {
Ok(HttpResponse::Ok().json(client.to_extended_user_info().await?))
}
/// Sign out user
pub async fn sign_out(
auth: AuthExtractor,
session: MatrixGWSession,
tx: web::Data<BroadcastSender>,
) -> HttpResult {
match auth.method {
AuthenticatedMethod::Cookie => {
session.unset_current_user()?;
}
AuthenticatedMethod::Token(token) => {
token.delete(&auth.user.email, &tx).await?;
}
AuthenticatedMethod::Dev => {
// Nothing to be done, user is always authenticated
}
}
Ok(HttpResponse::NoContent().finish())
}

View File

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

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

View File

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

View File

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

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

@@ -1,10 +1,14 @@
use actix_web::http::StatusCode;
use actix_web::{HttpResponse, ResponseError};
use std::error::Error;
use std::fmt::Debug;
pub mod api;
pub mod web_ui;
pub mod auth_controller;
pub mod matrix;
pub mod matrix_link_controller;
pub mod matrix_sync_thread_controller;
pub mod server_controller;
pub mod tokens_controller;
pub mod ws_controller;
#[derive(thiserror::Error, Debug)]
pub enum HttpFailure {
@@ -12,20 +16,20 @@ pub enum HttpFailure {
Forbidden,
#[error("this resource was not found")]
NotFound,
#[error("an unhandled session insert error occurred")]
SessionInsertError(#[from] actix_session::SessionInsertError),
#[error("an unhandled session error occurred")]
SessionError(#[from] actix_session::SessionGetError),
#[error("an unspecified open id error occurred: {0}")]
OpenID(Box<dyn Error>),
#[error("an error occurred while fetching user configuration: {0}")]
FetchUserConfig(anyhow::Error),
#[error("an unspecified internal error occurred: {0}")]
InternalError(#[from] anyhow::Error),
#[error("a matrix api client error occurred: {0}")]
MatrixApiClientError(#[from] ruma::api::client::Error),
#[error("a matrix client error occurred: {0}")]
MatrixClientError(String),
#[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 {
@@ -38,7 +42,9 @@ impl ResponseError for HttpFailure {
}
fn error_response(&self) -> HttpResponse {
HttpResponse::build(self.status_code()).body(self.to_string())
HttpResponse::build(self.status_code())
.content_type("text/plain")
.body(self.to_string())
}
}

View File

@@ -0,0 +1,74 @@
use crate::app_config::AppConfig;
use actix_web::HttpResponse;
/// Serve robots.txt (disallow ranking)
pub async fn robots_txt() -> HttpResponse {
HttpResponse::Ok()
.content_type("text/plain")
.body("User-agent: *\nDisallow: /\n")
}
#[derive(serde::Serialize)]
pub struct LenConstraints {
min: usize,
max: usize,
}
impl LenConstraints {
pub fn new(min: usize, max: usize) -> Self {
Self { min, max }
}
pub fn not_empty(max: usize) -> Self {
Self { min: 1, max }
}
pub fn max_only(max: usize) -> Self {
Self { min: 0, max }
}
pub fn check_str(&self, s: &str) -> bool {
s.len() >= self.min && s.len() <= self.max
}
pub fn check_u32(&self, v: u32) -> bool {
v >= self.min as u32 && v <= self.max as u32
}
}
#[derive(serde::Serialize)]
pub struct ServerConstraints {
pub token_name: LenConstraints,
pub token_ip_net: LenConstraints,
pub token_max_inactivity: LenConstraints,
}
impl Default for ServerConstraints {
fn default() -> Self {
Self {
token_name: LenConstraints::new(5, 255),
token_ip_net: LenConstraints::max_only(44),
token_max_inactivity: LenConstraints::new(3600, 3600 * 24 * 365),
}
}
}
#[derive(serde::Serialize)]
struct ServerConfig {
auth_disabled: bool,
oidc_provider_name: &'static str,
constraints: ServerConstraints,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
auth_disabled: AppConfig::get().is_auth_disabled(),
oidc_provider_name: AppConfig::get().openid_provider().name,
constraints: Default::default(),
}
}
}
/// Get server static configuration
pub async fn config() -> HttpResponse {
HttpResponse::Ok().json(ServerConfig::default())
}

View File

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

View File

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

View File

@@ -0,0 +1,334 @@
use crate::app_config::AppConfig;
use crate::constants;
use crate::extractors::session_extractor::MatrixGWSession;
use crate::users::{APIToken, APITokenID, User, UserEmail};
use crate::utils::time_utils::time_secs;
use actix_remote_ip::RemoteIP;
use actix_web::dev::Payload;
use actix_web::error::ErrorPreconditionFailed;
use actix_web::{FromRequest, HttpRequest};
use anyhow::Context;
use bytes::Bytes;
use jwt_simple::common::VerificationOptions;
use jwt_simple::prelude::{Duration, HS256Key, MACLike};
use jwt_simple::reexports::serde_json;
use serde::de::DeserializeOwned;
use sha2::{Digest, Sha256};
use std::fmt::Display;
use std::net::IpAddr;
use std::str::FromStr;
#[derive(Debug, Clone)]
pub enum AuthenticatedMethod {
/// User is authenticated using a cookie
Cookie,
/// User is authenticated through command line, for debugging purposes only
Dev,
/// User is authenticated using an API token
Token(APIToken),
}
impl AuthenticatedMethod {
pub fn light_str(&self) -> String {
match self {
AuthenticatedMethod::Cookie => "Cookie".to_string(),
AuthenticatedMethod::Dev => "DevAuthentication".to_string(),
AuthenticatedMethod::Token(t) => format!("Token({:?} - {})", t.id, t.base.name),
}
}
}
pub struct AuthExtractor {
pub user: User,
pub method: AuthenticatedMethod,
pub payload: Option<Vec<u8>>,
}
impl AsRef<User> for AuthExtractor {
fn as_ref(&self) -> &User {
&self.user
}
}
impl AuthExtractor {
pub fn decode_json_body<E: DeserializeOwned + Send>(&self) -> anyhow::Result<E> {
let payload = self
.payload
.as_ref()
.context("Failed to decode request as json: missing payload!")?;
Ok(serde_json::from_slice(payload)?)
}
}
#[derive(Debug, Eq, PartialEq)]
pub struct MatrixJWTKID {
pub user_email: UserEmail,
pub id: APITokenID,
}
impl Display for MatrixJWTKID {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}#{}", self.user_email.0, self.id.0)
}
}
impl FromStr for MatrixJWTKID {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let (mail, token_id) = s
.split_once("#")
.context("Failed to decode KID in two parts!")?;
let mail = UserEmail(mail.to_string());
if !mail.is_valid() {
anyhow::bail!("Given email is invalid!")
}
Ok(Self {
user_email: mail,
id: token_id.parse().context("Failed to parse API token ID")?,
})
}
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct TokenClaims {
#[serde(rename = "met")]
pub method: String,
pub uri: String,
#[serde(rename = "pay", skip_serializing_if = "Option::is_none")]
pub payload_sha256: Option<String>,
}
impl AuthExtractor {
async fn extract_auth(
req: &HttpRequest,
remote_ip: IpAddr,
payload_bytes: Option<Bytes>,
) -> Result<Self, actix_web::Error> {
// Check for authentication using API token
if let Some(token) = req.headers().get(constants::API_AUTH_HEADER) {
let Ok(jwt_token) = token.to_str() else {
return Err(actix_web::error::ErrorBadRequest(
"Failed to decode token as string!",
));
};
let metadata = match jwt_simple::token::Token::decode_metadata(jwt_token) {
Ok(m) => m,
Err(e) => {
log::error!("Failed to decode JWT header metadata! {e}");
return Err(actix_web::error::ErrorBadRequest(
"Failed to decode JWT header metadata!",
));
}
};
// Extract token ID
let Some(kid) = metadata.key_id() else {
return Err(actix_web::error::ErrorBadRequest(
"Missing key id in request!",
));
};
let jwt_kid = match MatrixJWTKID::from_str(kid) {
Ok(i) => i,
Err(e) => {
log::error!("Failed to parse token id! {e}");
return Err(actix_web::error::ErrorBadRequest(
"Failed to parse token id!",
));
}
};
// Get token information
let Ok(mut token) = APIToken::load(&jwt_kid.user_email, &jwt_kid.id).await else {
log::error!("Token not found!");
return Err(actix_web::error::ErrorForbidden("Token not found!"));
};
// Decode JWT
let key = HS256Key::from_bytes(token.secret.as_ref());
let verif = VerificationOptions {
max_validity: Some(Duration::from_secs(constants::API_TOKEN_JWT_MAX_DURATION)),
..Default::default()
};
let claims = match key.verify_token::<TokenClaims>(jwt_token, Some(verif)) {
Ok(t) => t,
Err(e) => {
log::error!("JWT validation failed! {e}");
return Err(actix_web::error::ErrorForbidden("JWT validation failed!"));
}
};
// Check for nonce
if claims.nonce.is_none() {
return Err(actix_web::error::ErrorBadRequest(
"A nonce is required in auth JWT!",
));
}
// Check IP restriction
if let Some(nets) = &token.base.networks
&& !nets.is_empty()
&& !nets.iter().any(|n| n.contains(&remote_ip))
{
log::error!(
"Trying to use token {:?} from unauthorized IP address: {remote_ip:?}",
token.id
);
return Err(actix_web::error::ErrorForbidden(
"This token cannot be used from this IP address!",
));
}
// Check for write access
if token.base.read_only && !req.method().is_safe() {
return Err(actix_web::error::ErrorBadRequest(
"Read only token cannot perform write operations!",
));
}
// Get user information
let Ok(user) = User::get_by_mail(&jwt_kid.user_email).await else {
return Err(actix_web::error::ErrorBadRequest(
"Failed to get user information from token!",
));
};
// Update last use (if needed)
if token.shall_update_time_used() {
token.last_used = time_secs();
if let Err(e) = token.write(&jwt_kid.user_email).await {
log::error!("Failed to refresh last usage of token! {e}");
}
}
// Handle tokens expiration
if token.is_expired() {
log::error!("Attempted to use expired token! {token:?}");
return Err(actix_web::error::ErrorBadRequest("Token has expired!"));
}
// Check payload
let payload = match (payload_bytes, claims.custom.payload_sha256) {
(None, _) => None,
(Some(_), None) => {
return Err(actix_web::error::ErrorBadRequest(
"A payload digest must be included in the JWT when the request has a payload!",
));
}
(Some(payload), Some(provided_digest)) => {
let computed_digest = base16ct::lower::encode_string(&Sha256::digest(&payload));
if computed_digest != provided_digest {
log::error!(
"Expected digest {provided_digest} for payload but computed {computed_digest}!"
);
return Err(actix_web::error::ErrorBadRequest(
"Computed digest is different from the one provided in the JWT!",
));
}
Some(payload.to_vec())
}
};
return Ok(Self {
method: AuthenticatedMethod::Token(token),
user,
payload,
});
}
// Check if login is hard-coded as program argument
if let Some(email) = &AppConfig::get().unsecure_auto_login_email() {
let user = User::get_by_mail(email).await.map_err(|e| {
log::error!("Failed to retrieve dev user: {e}");
ErrorPreconditionFailed("Unable to retrieve dev user!")
})?;
return Ok(Self {
method: AuthenticatedMethod::Dev,
user,
payload: payload_bytes.map(|bytes| bytes.to_vec()),
});
}
// Check for cookie authentication
let session = MatrixGWSession::extract(req).await?;
if let Some(mail) = session.current_user().map_err(|e| {
log::error!("Failed to retrieve user id: {e}");
ErrorPreconditionFailed("Failed to read session information!")
})? {
let user = User::get_by_mail(&mail).await.map_err(|e| {
log::error!("Failed to retrieve user from cookie session: {e}");
ErrorPreconditionFailed("Failed to retrieve user information!")
})?;
return Ok(Self {
method: AuthenticatedMethod::Cookie,
user,
payload: payload_bytes.map(|bytes| bytes.to_vec()),
});
};
Err(ErrorPreconditionFailed("Authentication required!"))
}
}
impl FromRequest for AuthExtractor {
type Error = actix_web::Error;
type Future = futures_util::future::LocalBoxFuture<'static, Result<Self, Self::Error>>;
fn from_request(req: &HttpRequest, payload: &mut Payload) -> Self::Future {
let req = req.clone();
let remote_ip = match RemoteIP::from_request(&req, &mut Payload::None).into_inner() {
Ok(ip) => ip,
Err(e) => return Box::pin(async { Err(e) }),
};
let mut payload = payload.take();
Box::pin(async move {
let payload_bytes = match Bytes::from_request(&req, &mut payload).await {
Ok(b) => {
if b.is_empty() {
None
} else {
Some(b)
}
}
Err(e) => {
log::error!("Failed to extract request payload! {e}");
None
}
};
Self::extract_auth(&req, remote_ip.0, payload_bytes).await
})
}
}
#[cfg(test)]
mod tests {
use crate::extractors::auth_extractor::MatrixJWTKID;
use crate::users::{APITokenID, UserEmail};
use std::str::FromStr;
#[test]
fn encode_decode_jwt_kid() {
let src = MatrixJWTKID {
user_email: UserEmail("test@mail.com".to_string()),
id: APITokenID::default(),
};
let encoded = src.to_string();
let decoded = encoded.parse::<MatrixJWTKID>().unwrap();
assert_eq!(src, decoded);
MatrixJWTKID::from_str("bad").unwrap_err();
MatrixJWTKID::from_str("ba#d").unwrap_err();
MatrixJWTKID::from_str("test@valid.com#d").unwrap_err();
}
}

View File

@@ -0,0 +1,54 @@
use crate::extractors::auth_extractor::AuthExtractor;
use crate::matrix_connection::matrix_client::MatrixClient;
use crate::matrix_connection::matrix_manager::MatrixManagerMsg;
use crate::users::ExtendedUserInfo;
use actix_web::dev::Payload;
use actix_web::{FromRequest, HttpRequest, web};
use ractor::ActorRef;
pub struct MatrixClientExtractor {
pub auth: AuthExtractor,
pub client: MatrixClient,
}
impl MatrixClientExtractor {
pub async fn to_extended_user_info(&self) -> anyhow::Result<ExtendedUserInfo> {
Ok(ExtendedUserInfo {
user: self.auth.user.clone(),
matrix_account_connected: self.client.is_client_connected(),
matrix_user_id: self.client.user_id().map(|id| id.to_string()),
matrix_device_id: self.client.device_id().map(|id| id.to_string()),
matrix_recovery_state: self.client.recovery_state(),
})
}
}
impl FromRequest for MatrixClientExtractor {
type Error = actix_web::Error;
type Future = futures_util::future::LocalBoxFuture<'static, Result<Self, Self::Error>>;
fn from_request(req: &HttpRequest, payload: &mut Payload) -> Self::Future {
let req = req.clone();
let mut payload = payload.take();
Box::pin(async move {
let auth = AuthExtractor::from_request(&req, &mut payload).await?;
let matrix_manager_actor =
web::Data::<ActorRef<MatrixManagerMsg>>::from_request(&req, &mut Payload::None)
.await?;
let client = ractor::call!(
matrix_manager_actor,
MatrixManagerMsg::GetClient,
auth.user.email.clone()
);
let client = match client {
Ok(Ok(client)) => client,
Ok(Err(err)) => panic!("Failed to get client! {err:?}"),
Err(err) => panic!("Failed to query manager actor! {err:#?}"),
};
Ok(Self { auth, client })
})
}
}

View File

@@ -0,0 +1,3 @@
pub mod auth_extractor;
pub mod matrix_client_extractor;
pub mod session_extractor;

View File

@@ -0,0 +1,91 @@
use crate::constants;
use crate::users::{User, UserEmail};
use crate::utils::rand_utils::rand_string;
use actix_session::Session;
use actix_web::dev::Payload;
use actix_web::{Error, FromRequest, HttpRequest};
use futures_util::future::{Ready, ready};
use std::net::IpAddr;
/// Matrix Gateway session errors
#[derive(thiserror::Error, Debug)]
enum MatrixGWSessionError {
#[error("Missing state!")]
OIDCMissingState,
#[error("Missing IP address!")]
OIDCMissingIP,
#[error("Invalid state!")]
OIDCInvalidState,
#[error("Invalid IP address!")]
OIDCInvalidIP,
}
/// Matrix Gateway session
///
/// Basic wrapper around actix-session extractor
pub struct MatrixGWSession(Session);
impl MatrixGWSession {
/// Generate OpenID state for this session
pub fn gen_oidc_state(&self, ip: IpAddr) -> anyhow::Result<String> {
let random_string = rand_string(50);
self.0
.insert(constants::sessions::OIDC_STATE_KEY, random_string.clone())?;
self.0.insert(constants::sessions::OIDC_REMOTE_IP, ip)?;
Ok(random_string)
}
/// Validate OpenID state
pub fn validate_state(&self, state: &str, ip: IpAddr) -> anyhow::Result<()> {
let session_state: String = self
.0
.get(constants::sessions::OIDC_STATE_KEY)?
.ok_or(MatrixGWSessionError::OIDCMissingState)?;
let session_ip: IpAddr = self
.0
.get(constants::sessions::OIDC_REMOTE_IP)?
.ok_or(MatrixGWSessionError::OIDCMissingIP)?;
if session_state != state {
return Err(anyhow::anyhow!(MatrixGWSessionError::OIDCInvalidState));
}
if session_ip != ip {
return Err(anyhow::anyhow!(MatrixGWSessionError::OIDCInvalidIP));
}
Ok(())
}
/// Set current user
pub fn set_user(&self, user: &User) -> anyhow::Result<()> {
self.0.insert(constants::sessions::USER_ID, &user.email)?;
Ok(())
}
/// Get current user
pub fn current_user(&self) -> anyhow::Result<Option<UserEmail>> {
Ok(self.0.get(constants::sessions::USER_ID)?)
}
/// Remove defined user
pub fn unset_current_user(&self) -> anyhow::Result<()> {
self.0.remove(constants::sessions::USER_ID);
Ok(())
}
}
impl FromRequest for MatrixGWSession {
type Error = Error;
type Future = Ready<Result<Self, Error>>;
#[inline]
fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
ready(
Session::from_request(req, &mut Payload::None)
.into_inner()
.map(MatrixGWSession),
)
}
}

View File

@@ -0,0 +1,8 @@
pub mod app_config;
pub mod broadcast_messages;
pub mod constants;
pub mod controllers;
pub mod extractors;
pub mod matrix_connection;
pub mod users;
pub mod utils;

View File

@@ -0,0 +1,208 @@
use actix_cors::Cors;
use actix_remote_ip::RemoteIPConfig;
use actix_session::SessionMiddleware;
use actix_session::config::SessionLifecycle;
use actix_session::storage::RedisSessionStore;
use actix_web::cookie::Key;
use actix_web::middleware::Logger;
use actix_web::{App, HttpServer, web};
use matrixgw_backend::app_config::AppConfig;
use matrixgw_backend::broadcast_messages::BroadcastMessage;
use matrixgw_backend::constants;
use matrixgw_backend::controllers::matrix::{
matrix_event_controller, matrix_media_controller, matrix_profile_controller,
matrix_room_controller,
};
use matrixgw_backend::controllers::{
auth_controller, matrix_link_controller, matrix_sync_thread_controller, server_controller,
tokens_controller, ws_controller,
};
use matrixgw_backend::matrix_connection::matrix_manager::MatrixManagerActor;
use matrixgw_backend::users::User;
use ractor::Actor;
#[tokio::main]
async fn main() -> std::io::Result<()> {
env_logger::init_from_env(env_logger::Env::new().default_filter_or("info"));
let secret_key = Key::from(AppConfig::get().secret().as_bytes());
log::info!("Connect to Redis session store...");
let redis_store = RedisSessionStore::new(AppConfig::get().redis_connection_string())
.await
.expect("Failed to connect to Redis!");
let (ws_tx, _) = tokio::sync::broadcast::channel::<BroadcastMessage>(16);
// Auto create default account, if requested
if let Some(mail) = &AppConfig::get().unsecure_auto_login_email() {
User::create_or_update_user(mail, "Anonymous")
.await
.expect("Failed to create auto-login account!");
}
// Create matrix clients manager actor
let (manager_actor, manager_actor_handle) = Actor::spawn(
Some("matrix-clients-manager".to_string()),
MatrixManagerActor,
ws_tx.clone(),
)
.await
.expect("Failed to start Matrix manager actor!");
log::info!(
"Starting to listen on {} for {}",
AppConfig::get().listen_address,
AppConfig::get().website_origin
);
let manager_actor_clone = manager_actor.clone();
HttpServer::new(move || {
let session_mw = SessionMiddleware::builder(redis_store.clone(), secret_key.clone())
.cookie_name("matrixgw-session".to_string())
.session_lifecycle(SessionLifecycle::BrowserSession(Default::default()))
.build();
let cors = Cors::default()
.allowed_origin(&AppConfig::get().website_origin)
.allowed_methods(["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"])
.allowed_header(constants::API_AUTH_HEADER)
.allow_any_header()
.supports_credentials()
.max_age(3600);
App::new()
.wrap(Logger::default())
.wrap(session_mw)
.wrap(cors)
.app_data(web::Data::new(manager_actor_clone.clone()))
.app_data(web::Data::new(RemoteIPConfig {
proxy: AppConfig::get().proxy_ip.clone(),
}))
.app_data(web::Data::new(ws_tx.clone()))
// Server controller
.route("/robots.txt", web::get().to(server_controller::robots_txt))
.route(
"/api/server/config",
web::get().to(server_controller::config),
)
// Auth controller
.route(
"/api/auth/start_oidc",
web::get().to(auth_controller::start_oidc),
)
.route(
"/api/auth/finish_oidc",
web::post().to(auth_controller::finish_oidc),
)
.route("/api/auth/info", web::get().to(auth_controller::auth_info))
.route(
"/api/auth/sign_out",
web::get().to(auth_controller::sign_out),
)
// Matrix link controller
.route(
"/api/matrix_link/start_auth",
web::post().to(matrix_link_controller::start_auth),
)
.route(
"/api/matrix_link/finish_auth",
web::post().to(matrix_link_controller::finish_auth),
)
.route(
"/api/matrix_link/logout",
web::post().to(matrix_link_controller::logout),
)
.route(
"/api/matrix_link/set_recovery_key",
web::post().to(matrix_link_controller::set_recovery_key),
)
// API Tokens controller
.route("/api/token", web::post().to(tokens_controller::create))
.route("/api/tokens", web::get().to(tokens_controller::get_list))
.route(
"/api/token/{id}",
web::delete().to(tokens_controller::delete),
)
// Matrix synchronization controller
.route(
"/api/matrix_sync/start",
web::post().to(matrix_sync_thread_controller::start_sync),
)
.route(
"/api/matrix_sync/stop",
web::post().to(matrix_sync_thread_controller::stop_sync),
)
.route(
"/api/matrix_sync/status",
web::get().to(matrix_sync_thread_controller::status),
)
.service(web::resource("/api/ws").route(web::get().to(ws_controller::ws)))
// Matrix room controller
.route(
"/api/matrix/room/joined",
web::get().to(matrix_room_controller::joined_rooms),
)
.route(
"/api/matrix/room/joined_spaces",
web::get().to(matrix_room_controller::get_joined_spaces),
)
.route(
"/api/matrix/room/{room_id}",
web::get().to(matrix_room_controller::single_room_info),
)
.route(
"/api/matrix/room/{room_id}/avatar",
web::get().to(matrix_room_controller::room_avatar),
)
// Matrix profile controller
.route(
"/api/matrix/profile/{user_id}",
web::get().to(matrix_profile_controller::get_profile),
)
.route(
"/api/matrix/profile/get_multiple",
web::post().to(matrix_profile_controller::get_multiple),
)
// Matrix events controller
.route(
"/api/matrix/room/{room_id}/events",
web::get().to(matrix_event_controller::get_for_room),
)
.route(
"/api/matrix/room/{room_id}/send_text_message",
web::post().to(matrix_event_controller::send_text_message),
)
.route(
"/api/matrix/room/{room_id}/event/{event_id}/set_text_content",
web::post().to(matrix_event_controller::set_text_content),
)
.route(
"/api/matrix/room/{room_id}/event/{event_id}/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),
)
// Matrix media controller
.route(
"/api/matrix/media/{mxc}",
web::get().to(matrix_media_controller::serve_mxc_handler),
)
})
.workers(4)
.bind(&AppConfig::get().listen_address)?
.run()
.await?;
// Terminate manager actor
manager_actor.stop(None);
manager_actor_handle.await?;
Ok(())
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,288 @@
use crate::app_config::AppConfig;
use crate::broadcast_messages::{BroadcastMessage, BroadcastSender};
use crate::constants;
use crate::controllers::server_controller::ServerConstraints;
use crate::matrix_connection::matrix_client::EncryptionRecoveryState;
use crate::utils::rand_utils::rand_string;
use crate::utils::time_utils::time_secs;
use anyhow::Context;
use jwt_simple::reexports::serde_json;
use std::cmp::min;
use std::str::FromStr;
/// Matrix Gateway user errors
#[derive(thiserror::Error, Debug)]
enum MatrixGWUserError {
#[error("Failed to load user metadata: {0}")]
LoadUserMetadata(std::io::Error),
#[error("Failed to decode user metadata: {0}")]
DecodeUserMetadata(serde_json::Error),
#[error("Failed to save user metadata: {0}")]
SaveUserMetadata(std::io::Error),
#[error("Failed to create API token directory: {0}")]
CreateApiTokensDirectory(std::io::Error),
#[error("Failed to delete API token: {0}")]
DeleteToken(std::io::Error),
#[error("Failed to load API token: {0}")]
LoadApiToken(std::io::Error),
#[error("Failed to decode API token: {0}")]
DecodeApiToken(serde_json::Error),
#[error("API Token does not exists!")]
ApiTokenDoesNotExists,
#[error("Failed to save API token: {0}")]
SaveAPIToken(std::io::Error),
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq, Hash)]
pub struct UserEmail(pub String);
impl UserEmail {
pub fn is_valid(&self) -> bool {
mailchecker::is_valid(&self.0)
}
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Eq, PartialEq)]
pub struct APITokenID(pub uuid::Uuid);
impl Default for APITokenID {
fn default() -> Self {
Self(uuid::Uuid::new_v4())
}
}
impl FromStr for APITokenID {
type Err = uuid::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self(uuid::Uuid::from_str(s)?))
}
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
pub struct User {
pub email: UserEmail,
pub name: String,
pub time_create: u64,
pub last_login: u64,
}
impl User {
/// Get a user by its mail
pub async fn get_by_mail(mail: &UserEmail) -> anyhow::Result<Self> {
let path = AppConfig::get().user_metadata_file_path(mail);
let data = std::fs::read_to_string(path).map_err(MatrixGWUserError::LoadUserMetadata)?;
Ok(serde_json::from_str(&data).map_err(MatrixGWUserError::DecodeUserMetadata)?)
}
/// Update user metadata on disk
pub async fn write(&self) -> anyhow::Result<()> {
let path = AppConfig::get().user_metadata_file_path(&self.email);
std::fs::write(&path, serde_json::to_string(&self)?)
.map_err(MatrixGWUserError::SaveUserMetadata)?;
Ok(())
}
/// Create or update user information
pub async fn create_or_update_user(mail: &UserEmail, name: &str) -> anyhow::Result<User> {
let storage_dir = AppConfig::get().user_directory(mail);
let mut user = if !storage_dir.exists() {
std::fs::create_dir_all(storage_dir)?;
User {
email: mail.clone(),
name: name.to_string(),
time_create: time_secs(),
last_login: time_secs(),
}
} else {
Self::get_by_mail(mail).await?
};
// Update some user information
user.name = name.to_string();
user.last_login = time_secs();
user.write().await?;
Ok(user)
}
}
/// Base API token information
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
pub struct BaseAPIToken {
/// Token name
pub name: String,
/// Restricted API network for token
pub networks: Option<Vec<ipnet::IpNet>>,
/// Token max inactivity
pub max_inactivity: u32,
/// Token expiration
pub expiration: Option<u64>,
/// Read only access
pub read_only: bool,
}
impl BaseAPIToken {
/// Check API token information validity
pub fn check(&self) -> Option<&'static str> {
let constraints = ServerConstraints::default();
if !lazy_regex::regex!("^[a-zA-Z0-9 :-]+$").is_match(&self.name) {
return Some("Token name contains invalid characters!");
}
if !constraints.token_name.check_str(&self.name) {
return Some("Invalid token name length!");
}
if !constraints
.token_max_inactivity
.check_u32(self.max_inactivity)
{
return Some("Invalid token max inactivity!");
}
if let Some(expiration) = self.expiration
&& expiration <= time_secs()
{
return Some("Given expiration time is in the past!");
}
None
}
}
/// Single API token information
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
pub struct APIToken {
#[serde(flatten)]
pub base: BaseAPIToken,
/// Token unique ID
pub id: APITokenID,
/// Client secret
pub secret: String,
/// Client creation time
pub created: u64,
/// Client last usage time
pub last_used: u64,
}
impl APIToken {
/// Get the list of tokens of a user
pub async fn list_user(email: &UserEmail) -> anyhow::Result<Vec<Self>> {
let tokens_dir = AppConfig::get().user_api_token_directory(email);
if !tokens_dir.exists() {
return Ok(vec![]);
}
let mut list = vec![];
for u in std::fs::read_dir(&tokens_dir)? {
let entry = u?;
list.push(
Self::load(
email,
&APITokenID::from_str(
entry
.file_name()
.to_str()
.context("Cannot decode API Token ID as string!")?,
)?,
)
.await?,
);
}
Ok(list)
}
/// Create a new token
pub async fn create(email: &UserEmail, base: BaseAPIToken) -> anyhow::Result<Self> {
let tokens_dir = AppConfig::get().user_api_token_directory(email);
if !tokens_dir.exists() {
std::fs::create_dir_all(tokens_dir)
.map_err(MatrixGWUserError::CreateApiTokensDirectory)?;
}
let token = APIToken {
base,
id: Default::default(),
secret: rand_string(constants::TOKENS_LEN),
created: time_secs(),
last_used: time_secs(),
};
token.write(email).await?;
Ok(token)
}
/// Get a token information
pub async fn load(email: &UserEmail, id: &APITokenID) -> anyhow::Result<Self> {
let token_file = AppConfig::get().user_api_token_metadata_file(email, id);
match token_file.exists() {
true => Ok(serde_json::from_str::<Self>(
&std::fs::read_to_string(&token_file).map_err(MatrixGWUserError::LoadApiToken)?,
)
.map_err(MatrixGWUserError::DecodeApiToken)?),
false => Err(MatrixGWUserError::ApiTokenDoesNotExists.into()),
}
}
/// Write this token information
pub async fn write(&self, mail: &UserEmail) -> anyhow::Result<()> {
let path = AppConfig::get().user_api_token_metadata_file(mail, &self.id);
std::fs::write(&path, serde_json::to_string(&self)?)
.map_err(MatrixGWUserError::SaveAPIToken)?;
Ok(())
}
/// Delete this token
pub async fn delete(self, email: &UserEmail, tx: &BroadcastSender) -> anyhow::Result<()> {
let token_file = AppConfig::get().user_api_token_metadata_file(email, &self.id);
std::fs::remove_file(&token_file).map_err(MatrixGWUserError::DeleteToken)?;
if let Err(e) = tx.send(BroadcastMessage::APITokenDeleted(self)) {
log::error!("Failed to notify API token deletion! {e}");
}
Ok(())
}
pub fn shall_update_time_used(&self) -> bool {
let refresh_interval = min(600, self.base.max_inactivity / 10);
(self.last_used) < time_secs() - refresh_interval as u64
}
pub fn is_expired(&self) -> bool {
// Check for hard coded expiration
if let Some(exp_time) = self.base.expiration
&& exp_time < time_secs()
{
return true;
}
// Control max token inactivity
(self.last_used + self.base.max_inactivity as u64) < time_secs()
}
}
#[derive(serde::Serialize, Debug, Clone)]
pub struct ExtendedUserInfo {
#[serde(flatten)]
pub user: User,
pub matrix_account_connected: bool,
pub matrix_user_id: Option<String>,
pub matrix_device_id: Option<String>,
pub matrix_recovery_state: EncryptionRecoveryState,
}

View File

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

View File

@@ -0,0 +1,3 @@
pub mod crypt_utils;
pub mod rand_utils;
pub mod time_utils;

View File

@@ -0,0 +1,6 @@
use rand::distr::{Alphanumeric, SampleString};
/// Generate a random string of a given length
pub fn rand_string(len: usize) -> String {
Alphanumeric.sample_string(&mut rand::rng(), len)
}

View File

@@ -0,0 +1,9 @@
use std::time::{SystemTime, UNIX_EPOCH};
/// Get the current time since epoch
pub fn time_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
}

1
matrixgw_frontend/.env Normal file
View File

@@ -0,0 +1 @@
VITE_APP_BACKEND=http://localhost:8000/api

View File

@@ -0,0 +1 @@
VITE_APP_BACKEND=/api

24
matrixgw_frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,2 @@
# MatrixGW frontend
Built using React + TypeScript + Vite

View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

View File

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MatrixGW</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4339
matrixgw_frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
{
"name": "matrixgw_frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@fontsource/roboto": "^5.2.8",
"@mdi/js": "^7.4.47",
"@mdi/react": "^1.6.1",
"@mui/icons-material": "^7.3.5",
"@mui/material": "^7.3.5",
"@mui/x-data-grid": "^8.18.0",
"@mui/x-date-pickers": "^8.17.0",
"date-and-time": "^4.1.0",
"dayjs": "^1.11.19",
"emoji-picker-react": "^4.16.1",
"is-cidr": "^6.0.1",
"qrcode.react": "^4.2.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-json-view-lite": "^2.5.0",
"react-router": "^7.9.5"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
"@types/node": "^24.6.0",
"@types/react": "^19.1.16",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.4",
"eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.45.0",
"vite": "npm:rolldown-vite@7.1.14"
},
"overrides": {
"vite": "npm:rolldown-vite@7.1.14"
}
}

View File

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

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,69 @@
import React from "react";
import {
createBrowserRouter,
createRoutesFromElements,
Route,
RouterProvider,
} from "react-router";
import { AuthApi } from "./api/AuthApi";
import { ServerApi } from "./api/ServerApi";
import { APITokensRoute } from "./routes/APITokensRoute";
import { LoginRoute } from "./routes/auth/LoginRoute";
import { OIDCCbRoute } from "./routes/auth/OIDCCbRoute";
import { HomeRoute } from "./routes/HomeRoute";
import { MatrixAuthCallback } from "./routes/MatrixAuthCallback";
import { MatrixLinkRoute } from "./routes/MatrixLinkRoute";
import { NotFoundRoute } from "./routes/NotFoundRoute";
import { WSDebugRoute } from "./routes/WSDebugRoute";
import { BaseLoginPage } from "./widgets/auth/BaseLoginPage";
import BaseAuthenticatedPage from "./widgets/dashboard/BaseAuthenticatedPage";
interface AuthContext {
signedIn: boolean;
setSignedIn: (signedIn: boolean) => void;
}
const AuthContextK = React.createContext<AuthContext | null>(null);
export function App(): React.ReactElement {
const [signedIn, setSignedIn] = React.useState(AuthApi.SignedIn);
const context: AuthContext = {
signedIn: signedIn,
setSignedIn: (s) => {
setSignedIn(s);
location.reload();
},
};
const router = createBrowserRouter(
createRoutesFromElements(
signedIn || ServerApi.Config.auth_disabled ? (
<Route path="*" element={<BaseAuthenticatedPage />}>
<Route path="" element={<HomeRoute />} />
<Route path="matrix_link" element={<MatrixLinkRoute />} />
<Route path="matrix_auth_cb" element={<MatrixAuthCallback />} />
<Route path="tokens" element={<APITokensRoute />} />
<Route path="wsdebug" element={<WSDebugRoute />} />
<Route path="*" element={<NotFoundRoute />} />
</Route>
) : (
<Route path="*" element={<BaseLoginPage />}>
<Route path="" element={<LoginRoute />} />
<Route path="oidc_cb" element={<OIDCCbRoute />} />
<Route path="*" element={<NotFoundRoute />} />
</Route>
)
)
);
return (
<AuthContextK value={context}>
<RouterProvider router={router} />
</AuthContextK>
);
}
export function useAuth(): AuthContext {
return React.use(AuthContextK)!;
}

View File

@@ -0,0 +1,192 @@
import { AuthApi } from "./AuthApi";
interface RequestParams {
uri: string;
method: "GET" | "POST" | "DELETE" | "PATCH" | "PUT";
allowFail?: boolean;
jsonData?: any;
formData?: FormData;
upProgress?: (progress: number) => void;
downProgress?: (e: { progress: number; total: number }) => void;
}
interface APIResponse {
data: any;
status: number;
}
export class ApiError extends Error {
public code: number;
public data: number;
constructor(message: string, code: number, data: any) {
super(`HTTP status: ${code}\nMessage: ${message}\nData=${data}`);
this.code = code;
this.data = data;
}
}
export class APIClient {
/**
* Get backend URL
*/
static backendURL(): string {
const URL = import.meta.env.VITE_APP_BACKEND ?? "";
if (URL.length === 0) throw new Error("Backend URL undefined!");
return URL;
}
/**
* Get the full URL at which the backend can be contacted
*/
static ActualBackendURL(): string {
const backendURL = this.backendURL();
if (backendURL.startsWith("/")) return `${location.origin}${backendURL}`;
else return backendURL;
}
/**
* Check out whether the backend is accessed through
* HTTPS or not
*/
static IsBackendSecure(): boolean {
return this.ActualBackendURL().startsWith("https");
}
/**
* Perform a request on the backend
*/
static async exec(args: RequestParams): Promise<APIResponse> {
let body: string | undefined | FormData = undefined;
const headers: any = {};
// JSON request
if (args.jsonData) {
headers["Content-Type"] = "application/json";
body = JSON.stringify(args.jsonData);
}
// Form data request
else if (args.formData) {
body = args.formData;
}
const url = this.backendURL() + args.uri;
let data;
let status: number;
// Make the request with XMLHttpRequest
if (args.upProgress) {
const res: XMLHttpRequest = await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", (e) => {
args.upProgress!(e.loaded / e.total);
});
xhr.addEventListener("load", () => {
resolve(xhr);
});
xhr.addEventListener("error", () => {
reject(new Error("File upload failed"));
});
xhr.addEventListener("abort", () => {
reject(new Error("File upload aborted"));
});
xhr.addEventListener("timeout", () => {
reject(new Error("File upload timeout"));
});
xhr.open(args.method, url, true);
xhr.withCredentials = true;
for (const key in headers) {
if (Object.prototype.hasOwnProperty.call(headers, key))
xhr.setRequestHeader(key, headers[key]);
}
xhr.send(body);
});
status = res.status;
if (res.responseType === "json") data = JSON.parse(res.responseText);
else data = res.response;
}
// Make the request with fetch
else {
const res = await fetch(url, {
method: args.method,
body: body,
headers: headers,
credentials: "include",
});
// Process response
// JSON response
if (res.headers.get("content-type") === "application/json")
data = await res.json();
// Text / XML response
else if (
["application/xml", "text/plain"].includes(
res.headers.get("content-type") ?? ""
)
)
data = await res.text();
// Binary file, tracking download progress
else if (res.body !== null && args.downProgress) {
// Track download progress
const contentEncoding = res.headers.get("content-encoding");
const contentLength = contentEncoding
? null
: res.headers.get("content-length");
const total = parseInt(contentLength ?? "0", 10);
let loaded = 0;
const resInt = new Response(
new ReadableStream({
start(controller) {
const reader = res.body!.getReader();
const read = async () => {
try {
const ret = await reader.read();
if (ret.done) {
controller.close();
return;
}
loaded += ret.value.byteLength;
args.downProgress!({ progress: loaded, total });
controller.enqueue(ret.value);
read();
} catch (e) {
console.error(e);
controller.error(e);
}
};
read();
},
})
);
data = await resInt.blob();
}
// Do not track progress (binary file)
else data = await res.blob();
status = res.status;
}
// Handle expired tokens
if (status === 412) {
AuthApi.UnsetAuthenticated();
window.location.href = "/";
}
if (!args.allowFail && (status < 200 || status > 299))
throw new ApiError("Request failed!", status, data);
return {
data: data,
status: status,
};
}
}

View File

@@ -0,0 +1,91 @@
import { APIClient } from "./ApiClient";
export interface UserInfo {
id: number;
time_create: number;
time_update: number;
name: string;
email: string;
matrix_account_connected: boolean;
matrix_user_id?: string;
matrix_device_id?: string;
matrix_recovery_state?: "Enabled" | "Disabled" | "Unknown" | "Incomplete";
}
const TokenStateKey = "auth-state";
export class AuthApi {
/**
* Check out whether user is signed in or not
*/
static get SignedIn(): boolean {
return localStorage.getItem(TokenStateKey) !== null;
}
/**
* Mark user as authenticated
*/
static SetAuthenticated() {
localStorage.setItem(TokenStateKey, "");
}
/**
* Un-mark user as authenticated
*/
static UnsetAuthenticated() {
localStorage.removeItem(TokenStateKey);
}
/**
* Start OpenID login
*/
static async StartOpenIDLogin(): Promise<{ url: string }> {
return (
await APIClient.exec({
uri: "/auth/start_oidc",
method: "GET",
})
).data;
}
/**
* Finish OpenID login
*/
static async FinishOpenIDLogin(code: string, state: string): Promise<void> {
await APIClient.exec({
uri: "/auth/finish_oidc",
method: "POST",
jsonData: { code: code, state: state },
});
this.SetAuthenticated();
}
/**
* Get user information
*/
static async GetUserInfo(): Promise<UserInfo> {
return (
await APIClient.exec({
uri: "/auth/info",
method: "GET",
})
).data;
}
/**
* Sign out
*/
static async SignOut(): Promise<void> {
try {
await APIClient.exec({
uri: "/auth/sign_out",
method: "GET",
});
} catch (e) {
console.error("Failed to sign out user on API!", e);
}
this.UnsetAuthenticated();
}
}

View File

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

View File

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

View File

@@ -0,0 +1,48 @@
import { APIClient } from "./ApiClient";
export interface ServerConfig {
auth_disabled: boolean;
oidc_provider_name: string;
constraints: ServerConstraints;
}
export interface AccountType {
label: string;
code: string;
icon: string;
}
export interface ServerConstraints {
token_name: LenConstraint;
token_ip_net: LenConstraint;
token_max_inactivity: LenConstraint;
}
export interface LenConstraint {
min: number;
max: number;
}
let config: ServerConfig | null = null;
export class ServerApi {
/**
* Get server configuration
*/
static async LoadConfig(): Promise<void> {
config = (
await APIClient.exec({
uri: "/server/config",
method: "GET",
})
).data;
}
/**
* Get cached configuration
*/
static get Config(): ServerConfig {
if (config === null) throw new Error("Missing configuration!");
return config;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,68 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
} from "@mui/material";
import React, { type PropsWithChildren } from "react";
type AlertContext = (message: string, title?: string) => Promise<void>;
const AlertContextK = React.createContext<AlertContext | null>(null);
export function AlertDialogProvider(p: PropsWithChildren): React.ReactElement {
const [open, setOpen] = React.useState(false);
const [title, setTitle] = React.useState<string | undefined>(undefined);
const [message, setMessage] = React.useState("");
const cb = React.useRef<null | (() => void)>(null);
const handleClose = () => {
setOpen(false);
if (cb.current !== null) cb.current();
cb.current = null;
};
const hook: AlertContext = (message, title) => {
setTitle(title);
setMessage(message);
setOpen(true);
return new Promise((res) => {
cb.current = res;
});
};
return (
<>
<AlertContextK value={hook}>{p.children}</AlertContextK>
<Dialog
open={open}
onClose={handleClose}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
>
{title && <DialogTitle id="alert-dialog-title">{title}</DialogTitle>}
<DialogContent>
<DialogContentText id="alert-dialog-description">
{message}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleClose} autoFocus>
Ok
</Button>
</DialogActions>
</Dialog>
</>
);
}
export function useAlert(): AlertContext {
return React.use(AlertContextK)!;
}

View File

@@ -0,0 +1,98 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
} from "@mui/material";
import React, { type PropsWithChildren } from "react";
type ConfirmContext = (
message: string | React.ReactElement,
title?: string,
confirmButton?: string
) => Promise<boolean>;
const ConfirmContextK = React.createContext<ConfirmContext | null>(null);
export function ConfirmDialogProvider(
p: PropsWithChildren
): React.ReactElement {
const [open, setOpen] = React.useState(false);
const [title, setTitle] = React.useState<string | undefined>(undefined);
const [message, setMessage] = React.useState<string | React.ReactElement>("");
const [confirmButton, setConfirmButton] = React.useState<string | undefined>(
undefined
);
const cb = React.useRef<null | ((a: boolean) => void)>(null);
const handleClose = (confirm: boolean) => {
setOpen(false);
if (cb.current !== null) cb.current(confirm);
cb.current = null;
};
const hook: ConfirmContext = (message, title, confirmButton) => {
setTitle(title);
setMessage(message);
setConfirmButton(confirmButton);
setOpen(true);
return new Promise((res) => {
cb.current = res;
});
};
const keyUp = (e: React.KeyboardEvent) => {
if (e.code === "Enter") handleClose(true);
};
return (
<>
<ConfirmContextK value={hook}>{p.children}</ConfirmContextK>
<Dialog
open={open}
onClose={() => {
handleClose(false);
}}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
onKeyUp={keyUp}
>
{title && <DialogTitle id="alert-dialog-title">{title}</DialogTitle>}
<DialogContent>
<DialogContentText id="alert-dialog-description">
{message}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button
onClick={() => {
handleClose(false);
}}
autoFocus
>
Cancel
</Button>
<Button
onClick={() => {
handleClose(true);
}}
color="error"
>
{confirmButton ?? "Confirm"}
</Button>
</DialogActions>
</Dialog>
</>
);
}
export function useConfirm(): ConfirmContext {
return React.use(ConfirmContextK)!;
}

View File

@@ -0,0 +1,61 @@
import {
CircularProgress,
Dialog,
DialogContent,
DialogContentText,
} from "@mui/material";
import React, { type PropsWithChildren } from "react";
interface LoadingMessageContext {
show: (message: string) => void;
hide: () => void;
}
const LoadingMessageContextK =
React.createContext<LoadingMessageContext | null>(null);
export function LoadingMessageProvider(
p: PropsWithChildren
): React.ReactElement {
const [open, setOpen] = React.useState(0);
const [message, setMessage] = React.useState("");
const hook: LoadingMessageContext = {
show(message) {
setMessage(message);
setOpen((v) => v + 1);
},
hide() {
setOpen((v) => v - 1);
},
};
return (
<>
<LoadingMessageContextK value={hook}>{p.children}</LoadingMessageContextK>
<Dialog open={open > 0}>
<DialogContent>
<DialogContentText>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<CircularProgress style={{ marginRight: "15px" }} />
{message}
</div>
</DialogContentText>
</DialogContent>
</Dialog>
</>
);
}
export function useLoadingMessage(): LoadingMessageContext {
return React.use(LoadingMessageContextK)!;
}

View File

@@ -0,0 +1,41 @@
import { Snackbar } from "@mui/material";
import React, { type PropsWithChildren } from "react";
type SnackbarContext = (message: string, duration?: number) => void;
const SnackbarContextK = React.createContext<SnackbarContext | null>(null);
export function SnackbarProvider(p: PropsWithChildren): React.ReactElement {
const [open, setOpen] = React.useState(false);
const [message, setMessage] = React.useState("");
const [duration, setDuration] = React.useState(0);
const handleClose = () => {
setOpen(false);
};
const hook: SnackbarContext = (message, duration) => {
setMessage(message);
setDuration(duration ?? 6000);
setOpen(true);
};
return (
<>
<SnackbarContextK value={hook}>{p.children}</SnackbarContextK>
<Snackbar
open={open}
autoHideDuration={duration}
onClose={handleClose}
message={message}
/>
</>
);
}
export function useSnackbar(): SnackbarContext {
return React.use(SnackbarContextK)!;
}

View File

@@ -0,0 +1,18 @@
body {
margin: 0;
}
html,
body,
#root {
height: 100%;
}
#root {
display: flex;
flex-direction: column;
}
#root > div {
flex: 1;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,23 @@
import { Button } from "@mui/material";
import { RouterLink } from "../widgets/RouterLink";
export function NotFoundRoute(): React.ReactElement {
return (
<div
style={{
textAlign: "center",
flex: "1",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
}}
>
<h1>404 Not found</h1>
<p>The page you requested was not found!</p>
<RouterLink to="/">
<Button>Go back home</Button>
</RouterLink>
</div>
);
}

View File

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

View File

@@ -0,0 +1,50 @@
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 { AuthApi } from "../../api/AuthApi";
export function LoginRoute(): React.ReactElement {
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const authWithOpenID = async () => {
try {
setLoading(true);
const res = await AuthApi.StartOpenIDLogin();
window.location.href = res.url;
} catch (e) {
console.error(e);
setError("Failed to initialize OpenID login");
}
};
if (loading)
return (
<div style={{ textAlign: "center" }}>
<CircularProgress />
</div>
);
return (
<>
{error && (
<Alert style={{ width: "100%" }} severity="error">
{error}
</Alert>
)}
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Button
fullWidth
variant="outlined"
onClick={authWithOpenID}
startIcon={<Icon path={mdiOpenid} size={1} />}
>
Sign in with {ServerApi.Config.oidc_provider_name}
</Button>
</Box>
</>
);
}

View File

@@ -0,0 +1,53 @@
import { CircularProgress } from "@mui/material";
import { useEffect, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router";
import { AuthApi } from "../../api/AuthApi";
import { useAuth } from "../../App";
import { AuthSingleMessage } from "../../widgets/auth/AuthSingleMessage";
/**
* OpenID login callback route
*/
export function OIDCCbRoute(): React.ReactElement {
const auth = useAuth();
const navigate = useNavigate();
const [error, setError] = useState(false);
const [searchParams] = useSearchParams();
const code = searchParams.get("code");
const state = searchParams.get("state");
const count = useRef("");
useEffect(() => {
const load = async () => {
try {
if (count.current === code) {
return;
}
count.current = code!;
await AuthApi.FinishOpenIDLogin(code!, state!);
navigate("/");
auth.setSignedIn(true);
} catch (e) {
console.error(e);
setError(true);
}
};
load();
});
if (error)
return (
<AuthSingleMessage message="Failed to finalize OpenID authentication!" />
);
return (
<div style={{ textAlign: "center" }}>
<CircularProgress />
</div>
);
}

View File

@@ -0,0 +1,46 @@
import * as React from "react";
import { ThemeProvider, createTheme } from "@mui/material/styles";
import type { ThemeOptions } from "@mui/material/styles";
import { inputsCustomizations } from "./customizations/inputs";
import { dataDisplayCustomizations } from "./customizations/dataDisplay";
import { feedbackCustomizations } from "./customizations/feedback";
import { navigationCustomizations } from "./customizations/navigation";
import { surfacesCustomizations } from "./customizations/surfaces";
import { colorSchemes, typography, shadows, shape } from "./themePrimitives";
interface AppThemeProps {
themeComponents?: ThemeOptions["components"];
}
export function AppTheme(
props: React.PropsWithChildren<AppThemeProps>
): React.ReactElement {
const { children, themeComponents } = props;
const theme = React.useMemo(() => {
return createTheme({
// For more details about CSS variables configuration, see https://mui.com/material-ui/customization/css-theme-variables/configuration/
cssVariables: {
colorSchemeSelector: "data-mui-color-scheme",
cssVarPrefix: "template",
},
colorSchemes, // Recently added in v6 for building light & dark mode app, see https://mui.com/material-ui/customization/palette/#color-schemes
typography,
shadows,
shape,
components: {
...inputsCustomizations,
...dataDisplayCustomizations,
...feedbackCustomizations,
...navigationCustomizations,
...surfacesCustomizations,
...themeComponents,
},
});
}, [themeComponents]);
return (
<ThemeProvider theme={theme} disableTransitionOnChange>
{children}
</ThemeProvider>
);
}

View File

@@ -0,0 +1,2 @@
# Application Theme
Taken from https://github.com/mui/material-ui/tree/v7.3.4/docs/data/material/getting-started/templates/shared-theme

View File

@@ -0,0 +1,233 @@
import { buttonBaseClasses } from "@mui/material/ButtonBase";
import { chipClasses } from "@mui/material/Chip";
import { iconButtonClasses } from "@mui/material/IconButton";
import { alpha, type Components, type Theme } from "@mui/material/styles";
import { svgIconClasses } from "@mui/material/SvgIcon";
import { typographyClasses } from "@mui/material/Typography";
import { gray, green, red } from "../themePrimitives";
/* eslint-disable import/prefer-default-export */
export const dataDisplayCustomizations: Components<Theme> = {
MuiList: {
styleOverrides: {
root: {
padding: "8px",
display: "flex",
flexDirection: "column",
gap: 0,
},
},
},
MuiListItem: {
styleOverrides: {
root: ({ theme }) => ({
[`& .${svgIconClasses.root}`]: {
width: "1rem",
height: "1rem",
color: (theme.vars || theme).palette.text.secondary,
},
[`& .${typographyClasses.root}`]: {
fontWeight: 500,
},
[`& .${buttonBaseClasses.root}`]: {
display: "flex",
gap: 8,
padding: "2px 8px",
borderRadius: (theme.vars || theme).shape.borderRadius,
opacity: 0.7,
"&.Mui-selected": {
opacity: 1,
backgroundColor: alpha(theme.palette.action.selected, 0.3),
[`& .${svgIconClasses.root}`]: {
color: (theme.vars || theme).palette.text.primary,
},
"&:focus-visible": {
backgroundColor: alpha(theme.palette.action.selected, 0.3),
},
"&:hover": {
backgroundColor: alpha(theme.palette.action.selected, 0.5),
},
},
"&:focus-visible": {
backgroundColor: "transparent",
},
},
}),
},
},
MuiListItemText: {
styleOverrides: {
primary: ({ theme }) => ({
fontSize: theme.typography.body2.fontSize,
fontWeight: 500,
lineHeight: theme.typography.body2.lineHeight,
}),
secondary: ({ theme }) => ({
fontSize: theme.typography.caption.fontSize,
lineHeight: theme.typography.caption.lineHeight,
}),
},
},
MuiListSubheader: {
styleOverrides: {
root: ({ theme }) => ({
backgroundColor: "transparent",
padding: "4px 8px",
fontSize: theme.typography.caption.fontSize,
fontWeight: 500,
lineHeight: theme.typography.caption.lineHeight,
}),
},
},
MuiListItemIcon: {
styleOverrides: {
root: {
minWidth: 0,
},
},
},
MuiChip: {
defaultProps: {
size: "small",
},
styleOverrides: {
root: ({ theme }) => ({
border: "1px solid",
borderRadius: "999px",
[`& .${chipClasses.label}`]: {
fontWeight: 600,
},
variants: [
{
props: {
color: "default",
},
style: {
borderColor: gray[200],
backgroundColor: gray[100],
[`& .${chipClasses.label}`]: {
color: gray[500],
},
[`& .${chipClasses.icon}`]: {
color: gray[500],
},
...theme.applyStyles("dark", {
borderColor: gray[700],
backgroundColor: gray[800],
[`& .${chipClasses.label}`]: {
color: gray[300],
},
[`& .${chipClasses.icon}`]: {
color: gray[300],
},
}),
},
},
{
props: {
color: "success",
},
style: {
borderColor: green[200],
backgroundColor: green[50],
[`& .${chipClasses.label}`]: {
color: green[500],
},
[`& .${chipClasses.icon}`]: {
color: green[500],
},
...theme.applyStyles("dark", {
borderColor: green[800],
backgroundColor: green[900],
[`& .${chipClasses.label}`]: {
color: green[300],
},
[`& .${chipClasses.icon}`]: {
color: green[300],
},
}),
},
},
{
props: {
color: "error",
},
style: {
borderColor: red[100],
backgroundColor: red[50],
[`& .${chipClasses.label}`]: {
color: red[500],
},
[`& .${chipClasses.icon}`]: {
color: red[500],
},
...theme.applyStyles("dark", {
borderColor: red[800],
backgroundColor: red[900],
[`& .${chipClasses.label}`]: {
color: red[200],
},
[`& .${chipClasses.icon}`]: {
color: red[300],
},
}),
},
},
{
props: { size: "small" },
style: {
maxHeight: 20,
[`& .${chipClasses.label}`]: {
fontSize: theme.typography.caption.fontSize,
},
[`& .${svgIconClasses.root}`]: {
fontSize: theme.typography.caption.fontSize,
},
},
},
{
props: { size: "medium" },
style: {
[`& .${chipClasses.label}`]: {
fontSize: theme.typography.caption.fontSize,
},
},
},
],
}),
},
},
MuiTablePagination: {
styleOverrides: {
actions: {
display: "flex",
gap: 8,
marginRight: 6,
[`& .${iconButtonClasses.root}`]: {
minWidth: 0,
width: 36,
height: 36,
},
},
},
},
MuiIcon: {
defaultProps: {
fontSize: "small",
},
styleOverrides: {
root: {
variants: [
{
props: {
fontSize: "small",
},
style: {
fontSize: "1rem",
},
},
],
},
},
},
};

View File

@@ -0,0 +1,46 @@
import { type Theme, alpha, type Components } from "@mui/material/styles";
import { gray, orange } from "../themePrimitives";
/* eslint-disable import/prefer-default-export */
export const feedbackCustomizations: Components<Theme> = {
MuiAlert: {
styleOverrides: {
root: ({ theme }) => ({
borderRadius: 10,
backgroundColor: orange[100],
color: (theme.vars || theme).palette.text.primary,
border: `1px solid ${alpha(orange[300], 0.5)}`,
"& .MuiAlert-icon": {
color: orange[500],
},
...theme.applyStyles("dark", {
backgroundColor: `${alpha(orange[900], 0.5)}`,
border: `1px solid ${alpha(orange[800], 0.5)}`,
}),
}),
},
},
MuiDialog: {
styleOverrides: {
root: ({ theme }) => ({
"& .MuiDialog-paper": {
borderRadius: "10px",
border: "1px solid",
borderColor: (theme.vars || theme).palette.divider,
},
}),
},
},
MuiLinearProgress: {
styleOverrides: {
root: ({ theme }) => ({
height: 8,
borderRadius: 8,
backgroundColor: gray[200],
...theme.applyStyles("dark", {
backgroundColor: gray[800],
}),
}),
},
},
};

View File

@@ -0,0 +1,452 @@
import { alpha, type Theme, type Components } from "@mui/material/styles";
import { outlinedInputClasses } from "@mui/material/OutlinedInput";
import { svgIconClasses } from "@mui/material/SvgIcon";
import { toggleButtonGroupClasses } from "@mui/material/ToggleButtonGroup";
import { toggleButtonClasses } from "@mui/material/ToggleButton";
import CheckBoxOutlineBlankRoundedIcon from "@mui/icons-material/CheckBoxOutlineBlankRounded";
import CheckRoundedIcon from "@mui/icons-material/CheckRounded";
import RemoveRoundedIcon from "@mui/icons-material/RemoveRounded";
import { gray, brand } from "../themePrimitives";
/* eslint-disable import/prefer-default-export */
export const inputsCustomizations: Components<Theme> = {
MuiButtonBase: {
defaultProps: {
disableTouchRipple: true,
disableRipple: true,
},
styleOverrides: {
root: ({ theme }) => ({
boxSizing: "border-box",
transition: "all 100ms ease-in",
"&:focus-visible": {
outline: `3px solid ${alpha(theme.palette.primary.main, 0.5)}`,
outlineOffset: "2px",
},
}),
},
},
MuiButton: {
styleOverrides: {
root: ({ theme }) => ({
boxShadow: "none",
borderRadius: (theme.vars || theme).shape.borderRadius,
textTransform: "none",
variants: [
{
props: {
size: "small",
},
style: {
height: "2.25rem",
padding: "8px 12px",
},
},
{
props: {
size: "medium",
},
style: {
height: "2.5rem", // 40px
},
},
{
props: {
color: "primary",
variant: "contained",
},
style: {
color: "white",
backgroundColor: gray[900],
backgroundImage: `linear-gradient(to bottom, ${gray[700]}, ${gray[800]})`,
boxShadow: `inset 0 1px 0 ${gray[600]}, inset 0 -1px 0 1px hsl(220, 0%, 0%)`,
border: `1px solid ${gray[700]}`,
"&:hover": {
backgroundImage: "none",
backgroundColor: gray[700],
boxShadow: "none",
},
"&:active": {
backgroundColor: gray[800],
},
...theme.applyStyles("dark", {
color: "black",
backgroundColor: gray[50],
backgroundImage: `linear-gradient(to bottom, ${gray[100]}, ${gray[50]})`,
boxShadow: "inset 0 -1px 0 hsl(220, 30%, 80%)",
border: `1px solid ${gray[50]}`,
"&:hover": {
backgroundImage: "none",
backgroundColor: gray[300],
boxShadow: "none",
},
"&:active": {
backgroundColor: gray[400],
},
}),
},
},
{
props: {
color: "secondary",
variant: "contained",
},
style: {
color: "white",
backgroundColor: brand[300],
backgroundImage: `linear-gradient(to bottom, ${alpha(
brand[400],
0.8
)}, ${brand[500]})`,
boxShadow: `inset 0 2px 0 ${alpha(
brand[200],
0.2
)}, inset 0 -2px 0 ${alpha(brand[700], 0.4)}`,
border: `1px solid ${brand[500]}`,
"&:hover": {
backgroundColor: brand[700],
boxShadow: "none",
},
"&:active": {
backgroundColor: brand[700],
backgroundImage: "none",
},
},
},
{
props: {
variant: "outlined",
},
style: {
color: (theme.vars || theme).palette.text.primary,
border: "1px solid",
borderColor: gray[200],
backgroundColor: alpha(gray[50], 0.3),
"&:hover": {
backgroundColor: gray[100],
borderColor: gray[300],
},
"&:active": {
backgroundColor: gray[200],
},
...theme.applyStyles("dark", {
backgroundColor: gray[800],
borderColor: gray[700],
"&:hover": {
backgroundColor: gray[900],
borderColor: gray[600],
},
"&:active": {
backgroundColor: gray[900],
},
}),
},
},
{
props: {
color: "secondary",
variant: "outlined",
},
style: {
color: brand[700],
border: "1px solid",
borderColor: brand[200],
backgroundColor: brand[50],
"&:hover": {
backgroundColor: brand[100],
borderColor: brand[400],
},
"&:active": {
backgroundColor: alpha(brand[200], 0.7),
},
...theme.applyStyles("dark", {
color: brand[50],
border: "1px solid",
borderColor: brand[900],
backgroundColor: alpha(brand[900], 0.3),
"&:hover": {
borderColor: brand[700],
backgroundColor: alpha(brand[900], 0.6),
},
"&:active": {
backgroundColor: alpha(brand[900], 0.5),
},
}),
},
},
{
props: {
variant: "text",
},
style: {
color: gray[600],
"&:hover": {
backgroundColor: gray[100],
},
"&:active": {
backgroundColor: gray[200],
},
...theme.applyStyles("dark", {
color: gray[50],
"&:hover": {
backgroundColor: gray[700],
},
"&:active": {
backgroundColor: alpha(gray[700], 0.7),
},
}),
},
},
{
props: {
color: "secondary",
variant: "text",
},
style: {
color: brand[700],
"&:hover": {
backgroundColor: alpha(brand[100], 0.5),
},
"&:active": {
backgroundColor: alpha(brand[200], 0.7),
},
...theme.applyStyles("dark", {
color: brand[100],
"&:hover": {
backgroundColor: alpha(brand[900], 0.5),
},
"&:active": {
backgroundColor: alpha(brand[900], 0.3),
},
}),
},
},
],
}),
},
},
MuiIconButton: {
styleOverrides: {
root: ({ theme }) => ({
boxShadow: "none",
borderRadius: (theme.vars || theme).shape.borderRadius,
textTransform: "none",
fontWeight: theme.typography.fontWeightMedium,
letterSpacing: 0,
color: (theme.vars || theme).palette.text.primary,
border: "1px solid ",
borderColor: gray[200],
backgroundColor: alpha(gray[50], 0.3),
"&:hover": {
backgroundColor: gray[100],
borderColor: gray[300],
},
"&:active": {
backgroundColor: gray[200],
},
...theme.applyStyles("dark", {
backgroundColor: gray[800],
borderColor: gray[700],
"&:hover": {
backgroundColor: gray[900],
borderColor: gray[600],
},
"&:active": {
backgroundColor: gray[900],
},
}),
variants: [
{
props: {
size: "small",
},
style: {
width: "2.25rem",
height: "2.25rem",
padding: "0.25rem",
[`& .${svgIconClasses.root}`]: { fontSize: "1rem" },
},
},
{
props: {
size: "medium",
},
style: {
width: "2.5rem",
height: "2.5rem",
},
},
],
}),
},
},
MuiToggleButtonGroup: {
styleOverrides: {
root: ({ theme }) => ({
borderRadius: "10px",
boxShadow: `0 4px 16px ${alpha(gray[400], 0.2)}`,
[`& .${toggleButtonGroupClasses.selected}`]: {
color: brand[500],
},
...theme.applyStyles("dark", {
[`& .${toggleButtonGroupClasses.selected}`]: {
color: "#fff",
},
boxShadow: `0 4px 16px ${alpha(brand[700], 0.5)}`,
}),
}),
},
},
MuiToggleButton: {
styleOverrides: {
root: ({ theme }) => ({
padding: "12px 16px",
textTransform: "none",
borderRadius: "10px",
fontWeight: 500,
...theme.applyStyles("dark", {
color: gray[400],
boxShadow: "0 4px 16px rgba(0, 0, 0, 0.5)",
[`&.${toggleButtonClasses.selected}`]: {
color: brand[300],
},
}),
}),
},
},
MuiCheckbox: {
defaultProps: {
disableRipple: true,
icon: (
<CheckBoxOutlineBlankRoundedIcon
sx={{ color: "hsla(210, 0%, 0%, 0.0)" }}
/>
),
checkedIcon: <CheckRoundedIcon sx={{ height: 14, width: 14 }} />,
indeterminateIcon: <RemoveRoundedIcon sx={{ height: 14, width: 14 }} />,
},
styleOverrides: {
root: ({ theme }) => ({
margin: 10,
height: 16,
width: 16,
borderRadius: 5,
border: "1px solid ",
borderColor: alpha(gray[300], 0.8),
boxShadow: "0 0 0 1.5px hsla(210, 0%, 0%, 0.04) inset",
backgroundColor: alpha(gray[100], 0.4),
transition: "border-color, background-color, 120ms ease-in",
"&:hover": {
borderColor: brand[300],
},
"&.Mui-focusVisible": {
outline: `3px solid ${alpha(brand[500], 0.5)}`,
outlineOffset: "2px",
borderColor: brand[400],
},
"&.Mui-checked": {
color: "white",
backgroundColor: brand[500],
borderColor: brand[500],
boxShadow: `none`,
"&:hover": {
backgroundColor: brand[600],
},
},
...theme.applyStyles("dark", {
borderColor: alpha(gray[700], 0.8),
boxShadow: "0 0 0 1.5px hsl(210, 0%, 0%) inset",
backgroundColor: alpha(gray[900], 0.8),
"&:hover": {
borderColor: brand[300],
},
"&.Mui-focusVisible": {
borderColor: brand[400],
outline: `3px solid ${alpha(brand[500], 0.5)}`,
outlineOffset: "2px",
},
}),
}),
},
},
MuiInputBase: {
styleOverrides: {
root: {
border: "none",
},
input: {
"&::placeholder": {
opacity: 0.7,
color: gray[500],
},
},
},
},
MuiOutlinedInput: {
styleOverrides: {
input: {
padding: 0,
},
root: ({ theme }) => ({
padding: "8px 12px",
color: (theme.vars || theme).palette.text.primary,
borderRadius: (theme.vars || theme).shape.borderRadius,
border: `1px solid ${(theme.vars || theme).palette.divider}`,
backgroundColor: (theme.vars || theme).palette.background.default,
transition: "border 120ms ease-in",
"&:hover": {
borderColor: gray[400],
},
[`&.${outlinedInputClasses.focused}`]: {
outline: `3px solid ${alpha(brand[500], 0.5)}`,
borderColor: brand[400],
},
...theme.applyStyles("dark", {
"&:hover": {
borderColor: gray[500],
},
}),
variants: [
{
props: {
size: "small",
},
style: {
height: "2.25rem",
},
},
{
props: {
size: "medium",
},
style: {
height: "2.5rem",
},
},
],
}),
notchedOutline: {
border: "none",
},
},
},
MuiInputAdornment: {
styleOverrides: {
root: ({ theme }) => ({
color: (theme.vars || theme).palette.grey[500],
...theme.applyStyles("dark", {
color: (theme.vars || theme).palette.grey[400],
}),
}),
},
},
MuiFormLabel: {
styleOverrides: {
root: ({ theme }) => ({
typography: theme.typography.caption,
marginBottom: 8,
}),
},
},
};

View File

@@ -0,0 +1,284 @@
import * as React from "react";
import { type Theme, alpha, type Components } from "@mui/material/styles";
import { type SvgIconProps } from "@mui/material/SvgIcon";
import { buttonBaseClasses } from "@mui/material/ButtonBase";
import { dividerClasses } from "@mui/material/Divider";
import { menuItemClasses } from "@mui/material/MenuItem";
import { selectClasses } from "@mui/material/Select";
import { tabClasses } from "@mui/material/Tab";
import UnfoldMoreRoundedIcon from "@mui/icons-material/UnfoldMoreRounded";
import { gray, brand } from "../themePrimitives";
/* eslint-disable import/prefer-default-export */
export const navigationCustomizations: Components<Theme> = {
MuiMenuItem: {
styleOverrides: {
root: ({ theme }) => ({
borderRadius: (theme.vars || theme).shape.borderRadius,
padding: "6px 8px",
[`&.${menuItemClasses.focusVisible}`]: {
backgroundColor: "transparent",
},
[`&.${menuItemClasses.selected}`]: {
[`&.${menuItemClasses.focusVisible}`]: {
backgroundColor: alpha(theme.palette.action.selected, 0.3),
},
},
}),
},
},
MuiMenu: {
styleOverrides: {
list: {
gap: "0px",
[`&.${dividerClasses.root}`]: {
margin: "0 -8px",
},
},
paper: ({ theme }) => ({
marginTop: "4px",
borderRadius: (theme.vars || theme).shape.borderRadius,
border: `1px solid ${(theme.vars || theme).palette.divider}`,
backgroundImage: "none",
background: "hsl(0, 0%, 100%)",
boxShadow:
"hsla(220, 30%, 5%, 0.07) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.07) 0px 8px 16px -5px",
[`& .${buttonBaseClasses.root}`]: {
"&.Mui-selected": {
backgroundColor: alpha(theme.palette.action.selected, 0.3),
},
},
...theme.applyStyles("dark", {
background: gray[900],
boxShadow:
"hsla(220, 30%, 5%, 0.7) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.8) 0px 8px 16px -5px",
}),
}),
},
},
MuiSelect: {
defaultProps: {
IconComponent: React.forwardRef<SVGSVGElement, SvgIconProps>(
(props, ref) => (
<UnfoldMoreRoundedIcon fontSize="small" {...props} ref={ref} />
)
),
},
styleOverrides: {
root: ({ theme }) => ({
borderRadius: (theme.vars || theme).shape.borderRadius,
border: "1px solid",
borderColor: gray[200],
backgroundColor: (theme.vars || theme).palette.background.paper,
boxShadow: `inset 0 1px 0 1px hsla(220, 0%, 100%, 0.6), inset 0 -1px 0 1px hsla(220, 35%, 90%, 0.5)`,
"&:hover": {
borderColor: gray[300],
backgroundColor: (theme.vars || theme).palette.background.paper,
boxShadow: "none",
},
[`&.${selectClasses.focused}`]: {
outlineOffset: 0,
borderColor: gray[400],
},
"&:before, &:after": {
display: "none",
},
...theme.applyStyles("dark", {
borderRadius: (theme.vars || theme).shape.borderRadius,
borderColor: gray[700],
backgroundColor: (theme.vars || theme).palette.background.paper,
boxShadow: `inset 0 1px 0 1px ${alpha(
gray[700],
0.15
)}, inset 0 -1px 0 1px hsla(220, 0%, 0%, 0.7)`,
"&:hover": {
borderColor: alpha(gray[700], 0.7),
backgroundColor: (theme.vars || theme).palette.background.paper,
boxShadow: "none",
},
[`&.${selectClasses.focused}`]: {
outlineOffset: 0,
borderColor: gray[900],
},
"&:before, &:after": {
display: "none",
},
}),
}),
select: ({ theme }) => ({
display: "flex",
alignItems: "center",
...theme.applyStyles("dark", {
display: "flex",
alignItems: "center",
"&:focus-visible": {
backgroundColor: gray[900],
},
}),
}),
},
},
MuiLink: {
defaultProps: {
underline: "none",
},
styleOverrides: {
root: ({ theme }) => ({
color: (theme.vars || theme).palette.text.primary,
fontWeight: 500,
position: "relative",
textDecoration: "none",
width: "fit-content",
"&::before": {
content: '""',
position: "absolute",
width: "100%",
height: "1px",
bottom: 0,
left: 0,
backgroundColor: (theme.vars || theme).palette.text.secondary,
opacity: 0.3,
transition: "width 0.3s ease, opacity 0.3s ease",
},
"&:hover::before": {
width: 0,
},
"&:focus-visible": {
outline: `3px solid ${alpha(brand[500], 0.5)}`,
outlineOffset: "4px",
borderRadius: "2px",
},
}),
},
},
MuiDrawer: {
styleOverrides: {
paper: ({ theme }) => ({
backgroundColor: (theme.vars || theme).palette.background.default,
}),
},
},
MuiPaginationItem: {
styleOverrides: {
root: ({ theme }) => ({
"&.Mui-selected": {
color: "white",
backgroundColor: (theme.vars || theme).palette.grey[900],
},
...theme.applyStyles("dark", {
"&.Mui-selected": {
color: "black",
backgroundColor: (theme.vars || theme).palette.grey[50],
},
}),
}),
},
},
MuiTabs: {
styleOverrides: {
root: { minHeight: "fit-content" },
indicator: ({ theme }) => ({
backgroundColor: (theme.vars || theme).palette.grey[800],
...theme.applyStyles("dark", {
backgroundColor: (theme.vars || theme).palette.grey[200],
}),
}),
},
},
MuiTab: {
styleOverrides: {
root: ({ theme }) => ({
padding: "6px 8px",
marginBottom: "8px",
textTransform: "none",
minWidth: "fit-content",
minHeight: "fit-content",
color: (theme.vars || theme).palette.text.secondary,
borderRadius: (theme.vars || theme).shape.borderRadius,
border: "1px solid",
borderColor: "transparent",
":hover": {
color: (theme.vars || theme).palette.text.primary,
backgroundColor: gray[100],
borderColor: gray[200],
},
[`&.${tabClasses.selected}`]: {
color: gray[900],
},
...theme.applyStyles("dark", {
":hover": {
color: (theme.vars || theme).palette.text.primary,
backgroundColor: gray[800],
borderColor: gray[700],
},
[`&.${tabClasses.selected}`]: {
color: "#fff",
},
}),
}),
},
},
MuiStepConnector: {
styleOverrides: {
line: ({ theme }) => ({
borderTop: "1px solid",
borderColor: (theme.vars || theme).palette.divider,
flex: 1,
borderRadius: "99px",
}),
},
},
MuiStepIcon: {
styleOverrides: {
root: ({ theme }) => ({
color: "transparent",
border: `1px solid ${gray[400]}`,
width: 12,
height: 12,
borderRadius: "50%",
"& text": {
display: "none",
},
"&.Mui-active": {
border: "none",
color: (theme.vars || theme).palette.primary.main,
},
"&.Mui-completed": {
border: "none",
color: (theme.vars || theme).palette.success.main,
},
...theme.applyStyles("dark", {
border: `1px solid ${gray[700]}`,
"&.Mui-active": {
border: "none",
color: (theme.vars || theme).palette.primary.light,
},
"&.Mui-completed": {
border: "none",
color: (theme.vars || theme).palette.success.light,
},
}),
variants: [
{
props: { completed: true },
style: {
width: 12,
height: 12,
},
},
],
}),
},
},
MuiStepLabel: {
styleOverrides: {
label: ({ theme }) => ({
"&.Mui-completed": {
opacity: 0.6,
...theme.applyStyles("dark", { opacity: 0.5 }),
},
}),
},
},
};

View File

@@ -0,0 +1,113 @@
import { alpha, type Theme, type Components } from "@mui/material/styles";
import { gray } from "../themePrimitives";
/* eslint-disable import/prefer-default-export */
export const surfacesCustomizations: Components<Theme> = {
MuiAccordion: {
defaultProps: {
elevation: 0,
disableGutters: true,
},
styleOverrides: {
root: ({ theme }) => ({
padding: 4,
overflow: "clip",
backgroundColor: (theme.vars || theme).palette.background.default,
border: "1px solid",
borderColor: (theme.vars || theme).palette.divider,
":before": {
backgroundColor: "transparent",
},
"&:not(:last-of-type)": {
borderBottom: "none",
},
"&:first-of-type": {
borderTopLeftRadius: (theme.vars || theme).shape.borderRadius,
borderTopRightRadius: (theme.vars || theme).shape.borderRadius,
},
"&:last-of-type": {
borderBottomLeftRadius: (theme.vars || theme).shape.borderRadius,
borderBottomRightRadius: (theme.vars || theme).shape.borderRadius,
},
}),
},
},
MuiAccordionSummary: {
styleOverrides: {
root: ({ theme }) => ({
border: "none",
borderRadius: 8,
"&:hover": { backgroundColor: gray[50] },
"&:focus-visible": { backgroundColor: "transparent" },
...theme.applyStyles("dark", {
"&:hover": { backgroundColor: gray[800] },
}),
}),
},
},
MuiAccordionDetails: {
styleOverrides: {
root: { mb: 20, border: "none" },
},
},
MuiPaper: {
defaultProps: {
elevation: 0,
},
},
MuiCard: {
styleOverrides: {
root: ({ theme }) => {
return {
padding: 16,
gap: 16,
transition: "all 100ms ease",
backgroundColor: gray[50],
borderRadius: (theme.vars || theme).shape.borderRadius,
border: `1px solid ${(theme.vars || theme).palette.divider}`,
boxShadow: "none",
...theme.applyStyles("dark", {
backgroundColor: gray[800],
}),
variants: [
{
props: {
variant: "outlined",
},
style: {
border: `1px solid ${(theme.vars || theme).palette.divider}`,
boxShadow: "none",
background: "hsl(0, 0%, 100%)",
...theme.applyStyles("dark", {
background: alpha(gray[900], 0.4),
}),
},
},
],
};
},
},
},
MuiCardContent: {
styleOverrides: {
root: {
padding: 0,
"&:last-child": { paddingBottom: 0 },
},
},
},
MuiCardHeader: {
styleOverrides: {
root: {
padding: 0,
},
},
},
MuiCardActions: {
styleOverrides: {
root: {
padding: 0,
},
},
},
};

View File

@@ -0,0 +1,414 @@
import {
createTheme,
alpha,
type PaletteMode,
type Shadows,
} from "@mui/material/styles";
declare module "@mui/material/Paper" {
interface PaperPropsVariantOverrides {
highlighted: true;
}
}
declare module "@mui/material/styles" {
interface ColorRange {
50: string;
100: string;
200: string;
300: string;
400: string;
500: string;
600: string;
700: string;
800: string;
900: string;
}
interface PaletteColor extends ColorRange {}
interface Palette {
baseShadow: string;
}
}
const defaultTheme = createTheme();
const customShadows: Shadows = [...defaultTheme.shadows];
export const brand = {
50: "hsl(210, 100%, 95%)",
100: "hsl(210, 100%, 92%)",
200: "hsl(210, 100%, 80%)",
300: "hsl(210, 100%, 65%)",
400: "hsl(210, 98%, 48%)",
500: "hsl(210, 98%, 42%)",
600: "hsl(210, 98%, 55%)",
700: "hsl(210, 100%, 35%)",
800: "hsl(210, 100%, 16%)",
900: "hsl(210, 100%, 21%)",
};
export const gray = {
50: "hsl(220, 35%, 97%)",
100: "hsl(220, 30%, 94%)",
200: "hsl(220, 20%, 88%)",
300: "hsl(220, 20%, 80%)",
400: "hsl(220, 20%, 65%)",
500: "hsl(220, 20%, 42%)",
600: "hsl(220, 20%, 35%)",
700: "hsl(220, 20%, 25%)",
800: "hsl(220, 30%, 6%)",
900: "hsl(220, 35%, 3%)",
};
export const green = {
50: "hsl(120, 80%, 98%)",
100: "hsl(120, 75%, 94%)",
200: "hsl(120, 75%, 87%)",
300: "hsl(120, 61%, 77%)",
400: "hsl(120, 44%, 53%)",
500: "hsl(120, 59%, 30%)",
600: "hsl(120, 70%, 25%)",
700: "hsl(120, 75%, 16%)",
800: "hsl(120, 84%, 10%)",
900: "hsl(120, 87%, 6%)",
};
export const orange = {
50: "hsl(45, 100%, 97%)",
100: "hsl(45, 92%, 90%)",
200: "hsl(45, 94%, 80%)",
300: "hsl(45, 90%, 65%)",
400: "hsl(45, 90%, 40%)",
500: "hsl(45, 90%, 35%)",
600: "hsl(45, 91%, 25%)",
700: "hsl(45, 94%, 20%)",
800: "hsl(45, 95%, 16%)",
900: "hsl(45, 93%, 12%)",
};
export const red = {
50: "hsl(0, 100%, 97%)",
100: "hsl(0, 92%, 90%)",
200: "hsl(0, 94%, 80%)",
300: "hsl(0, 90%, 65%)",
400: "hsl(0, 90%, 40%)",
500: "hsl(0, 90%, 30%)",
600: "hsl(0, 91%, 25%)",
700: "hsl(0, 94%, 18%)",
800: "hsl(0, 95%, 12%)",
900: "hsl(0, 93%, 6%)",
};
export const getDesignTokens = (mode: PaletteMode) => {
customShadows[1] =
mode === "dark"
? "hsla(220, 30%, 5%, 0.7) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.8) 0px 8px 16px -5px"
: "hsla(220, 30%, 5%, 0.07) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.07) 0px 8px 16px -5px";
return {
palette: {
mode,
primary: {
light: brand[200],
main: brand[400],
dark: brand[700],
contrastText: brand[50],
...(mode === "dark" && {
contrastText: brand[50],
light: brand[300],
main: brand[400],
dark: brand[700],
}),
},
info: {
light: brand[100],
main: brand[300],
dark: brand[600],
contrastText: gray[50],
...(mode === "dark" && {
contrastText: brand[300],
light: brand[500],
main: brand[700],
dark: brand[900],
}),
},
warning: {
light: orange[300],
main: orange[400],
dark: orange[800],
...(mode === "dark" && {
light: orange[400],
main: orange[500],
dark: orange[700],
}),
},
error: {
light: red[300],
main: red[400],
dark: red[800],
...(mode === "dark" && {
light: red[400],
main: red[500],
dark: red[700],
}),
},
success: {
light: green[300],
main: green[400],
dark: green[800],
...(mode === "dark" && {
light: green[400],
main: green[500],
dark: green[700],
}),
},
grey: {
...gray,
},
divider: mode === "dark" ? alpha(gray[700], 0.6) : alpha(gray[300], 0.4),
background: {
default: "hsl(0, 0%, 99%)",
paper: "hsl(220, 35%, 97%)",
...(mode === "dark" && {
default: gray[900],
paper: "hsl(220, 30%, 7%)",
}),
},
text: {
primary: gray[800],
secondary: gray[600],
warning: orange[400],
...(mode === "dark" && {
primary: "hsl(0, 0%, 100%)",
secondary: gray[400],
}),
},
action: {
hover: alpha(gray[200], 0.2),
selected: `${alpha(gray[200], 0.3)}`,
...(mode === "dark" && {
hover: alpha(gray[600], 0.2),
selected: alpha(gray[600], 0.3),
}),
},
},
typography: {
fontFamily: "Inter, sans-serif",
h1: {
fontSize: defaultTheme.typography.pxToRem(48),
fontWeight: 600,
lineHeight: 1.2,
letterSpacing: -0.5,
},
h2: {
fontSize: defaultTheme.typography.pxToRem(36),
fontWeight: 600,
lineHeight: 1.2,
},
h3: {
fontSize: defaultTheme.typography.pxToRem(30),
lineHeight: 1.2,
},
h4: {
fontSize: defaultTheme.typography.pxToRem(24),
fontWeight: 600,
lineHeight: 1.5,
},
h5: {
fontSize: defaultTheme.typography.pxToRem(20),
fontWeight: 600,
},
h6: {
fontSize: defaultTheme.typography.pxToRem(18),
fontWeight: 600,
},
subtitle1: {
fontSize: defaultTheme.typography.pxToRem(18),
},
subtitle2: {
fontSize: defaultTheme.typography.pxToRem(14),
fontWeight: 500,
},
body1: {
fontSize: defaultTheme.typography.pxToRem(14),
},
body2: {
fontSize: defaultTheme.typography.pxToRem(14),
fontWeight: 400,
},
caption: {
fontSize: defaultTheme.typography.pxToRem(12),
fontWeight: 400,
},
},
shape: {
borderRadius: 8,
},
shadows: customShadows,
};
};
export const colorSchemes = {
light: {
palette: {
primary: {
light: brand[200],
main: brand[400],
dark: brand[700],
contrastText: brand[50],
},
info: {
light: brand[100],
main: brand[300],
dark: brand[600],
contrastText: gray[50],
},
warning: {
light: orange[300],
main: orange[400],
dark: orange[800],
},
error: {
light: red[300],
main: red[400],
dark: red[800],
},
success: {
light: green[300],
main: green[400],
dark: green[800],
},
grey: {
...gray,
},
divider: alpha(gray[300], 0.4),
background: {
default: "hsl(0, 0%, 99%)",
paper: "hsl(220, 35%, 97%)",
},
text: {
primary: gray[800],
secondary: gray[600],
warning: orange[400],
},
action: {
hover: alpha(gray[200], 0.2),
selected: `${alpha(gray[200], 0.3)}`,
},
baseShadow:
"hsla(220, 30%, 5%, 0.07) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.07) 0px 8px 16px -5px",
},
},
dark: {
palette: {
primary: {
contrastText: brand[50],
light: brand[300],
main: brand[400],
dark: brand[700],
},
info: {
contrastText: brand[300],
light: brand[500],
main: brand[700],
dark: brand[900],
},
warning: {
light: orange[400],
main: orange[500],
dark: orange[700],
},
error: {
light: red[400],
main: red[500],
dark: red[700],
},
success: {
light: green[400],
main: green[500],
dark: green[700],
},
grey: {
...gray,
},
divider: alpha(gray[700], 0.6),
background: {
default: gray[900],
paper: "hsl(220, 30%, 7%)",
},
text: {
primary: "hsl(0, 0%, 100%)",
secondary: gray[400],
},
action: {
hover: alpha(gray[600], 0.2),
selected: alpha(gray[600], 0.3),
},
baseShadow:
"hsla(220, 30%, 5%, 0.7) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.8) 0px 8px 16px -5px",
},
},
};
export const typography = {
fontFamily: "Inter, sans-serif",
h1: {
fontSize: defaultTheme.typography.pxToRem(48),
fontWeight: 600,
lineHeight: 1.2,
letterSpacing: -0.5,
},
h2: {
fontSize: defaultTheme.typography.pxToRem(36),
fontWeight: 600,
lineHeight: 1.2,
},
h3: {
fontSize: defaultTheme.typography.pxToRem(30),
lineHeight: 1.2,
},
h4: {
fontSize: defaultTheme.typography.pxToRem(24),
fontWeight: 600,
lineHeight: 1.5,
},
h5: {
fontSize: defaultTheme.typography.pxToRem(20),
fontWeight: 600,
},
h6: {
fontSize: defaultTheme.typography.pxToRem(18),
fontWeight: 600,
},
subtitle1: {
fontSize: defaultTheme.typography.pxToRem(18),
},
subtitle2: {
fontSize: defaultTheme.typography.pxToRem(14),
fontWeight: 500,
},
body1: {
fontSize: defaultTheme.typography.pxToRem(14),
},
body2: {
fontSize: defaultTheme.typography.pxToRem(14),
fontWeight: 400,
},
caption: {
fontSize: defaultTheme.typography.pxToRem(12),
fontWeight: 400,
},
};
export const shape = {
borderRadius: 8,
};
// @ts-ignore
const defaultShadows: Shadows = [
"none",
"var(--template-palette-baseShadow)",
...defaultTheme.shadows.slice(2),
];
export const shadows = defaultShadows;

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,86 @@
import { Alert, Box, Button, CircularProgress } from "@mui/material";
import { useEffect, useRef, useState } from "react";
const State = {
Loading: 0,
Ready: 1,
Error: 2,
} as const;
type State = keyof typeof State;
export function AsyncWidget(p: {
loadKey: any;
load: () => Promise<void>;
errMsg: string;
build: () => React.ReactElement;
ready?: boolean;
errAdditionalElement?: () => React.ReactElement;
}): React.ReactElement {
const [state, setState] = useState<number>(State.Loading);
const counter = useRef<any>(null);
const load = async () => {
try {
setState(State.Loading);
await p.load();
setState(State.Ready);
} catch (e) {
console.error(e);
setState(State.Error);
}
};
useEffect(() => {
if (counter.current === p.loadKey) return;
counter.current = p.loadKey;
load();
});
if (state === State.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" }}
>
{p.errMsg}
</Alert>
<Button onClick={load}>Try again</Button>
{p.errAdditionalElement?.()}
</Box>
);
if (state === State.Loading || p.ready === false)
return (
<Box
component="div"
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
flex: "1",
}}
>
<CircularProgress />
</Box>
);
return p.build();
}

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