300 Commits

Author SHA1 Message Date
a6aaac024c chore(deps): update rust crate tokio to 1.50.0
Some checks failed
continuous-integration/drone/push Build is failing
2026-03-04 00:20:30 +00:00
fbdfbf2b5d Merge pull request 'chore(deps): update dependency @eslint/js to v10' (#194) from renovate/eslint-js-10.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2026-03-03 00:20:04 +00:00
68efa064df chore(deps): update dependency @eslint/js to v10
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2026-03-03 00:19:59 +00:00
e88961a43a fix: add space around send file button
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-02 23:05:11 +01:00
f6169d690f Remove useless lambda variable
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-02 22:53:50 +01:00
5221260e26 Updated frontend dependencies
Some checks failed
continuous-integration/drone/push Build is failing
2026-03-02 22:53:03 +01:00
c562152019 Merge branch 'master' of https://gitea.communiquons.org/pierre/MatrixGW
Some checks failed
continuous-integration/drone/push Build is failing
2026-03-02 22:49:56 +01:00
2ea20e6de4 feat: can upload files in conversations 2026-03-02 22:49:47 +01:00
29939d598f Merge pull request 'Update dependency react-router to ^7.13.1' (#193) from renovate/react-router-7.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-02 00:21:09 +00:00
b2ab5aceca Update dependency react-router to ^7.13.1
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2026-03-01 00:20:31 +00:00
2a5def11eb fix typo
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-28 15:38:13 +01:00
c9c3eca8f8 Merge pull request 'Update dependency @mui/x-data-grid to ^8.27.3' (#192) from renovate/mui-x-data-grid-8.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-28 00:15:30 +00:00
1bdb130fb4 Update dependency @mui/x-data-grid to ^8.27.3
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2026-02-27 00:14:11 +00:00
2c2a81c271 Merge pull request 'Update dependency eslint to ^9.39.3' (#191) from renovate/eslint-9.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-27 00:14:05 +00:00
09099bce9b Update dependency eslint to ^9.39.3
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2026-02-26 00:14:49 +00:00
16595f1fe2 Merge branch 'master' of https://gitea.communiquons.org/pierre/MatrixGW
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-24 23:10:34 +01:00
f19b3e802b Create prod Docker config 2026-02-24 23:10:10 +01:00
f68bfb9bee Merge pull request 'Update dependency @fontsource/roboto to ^5.2.10' (#190) from renovate/fontsource-roboto-5.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-24 00:14:36 +00:00
2d17188fbe Update dependency @fontsource/roboto to ^5.2.10
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2026-02-23 00:15:23 +00:00
ea0464a5b0 Merge pull request 'Update dependency @mui/x-date-pickers to ^8.27.2' (#189) from renovate/mui-x-date-pickers-8.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-23 00:15:16 +00:00
ed92669aba Update dependency @mui/x-date-pickers to ^8.27.2
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2026-02-22 00:15:37 +00:00
50b0f6d479 Merge pull request 'Update dependency @eslint/js to ^9.39.3' (#188) from renovate/eslint-js-9.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-22 00:15:30 +00:00
953764a3a4 Update dependency @eslint/js to ^9.39.3
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2026-02-21 00:16:21 +00:00
fa4cb2c556 Merge pull request 'Update dependency is-cidr to ^6.0.3' (#185) from renovate/is-cidr-6.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-21 00:16:14 +00:00
23cfda38c6 Update dependency is-cidr to ^6.0.3
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2026-02-20 00:15:49 +00:00
894dbe033c Updated project dependencies
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-19 17:57:27 +01:00
ac32758de6 Merge pull request 'Update dependency @mui/x-date-pickers to ^8.27.0' (#187) from renovate/mui-x-date-pickers-8.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-18 00:28:12 +00:00
c2944f203b Merge pull request 'Update materialui to ^7.3.8' (#186) from renovate/materialui into master
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-18 00:28:05 +00:00
01dc8bf910 Update dependency @mui/x-date-pickers to ^8.27.0
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2026-02-17 00:28:09 +00:00
4f3ad57d3a Update materialui to ^7.3.8
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2026-02-17 00:28:05 +00:00
94f8a34288 Merge pull request 'Update dependency @mui/x-data-grid to ^8.27.1' (#184) from renovate/mui-x-data-grid-8.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-16 00:27:57 +00:00
1df0855e47 Update dependency @mui/x-data-grid to ^8.27.1
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2026-02-15 00:28:13 +00:00
f87328a1b2 Merge pull request 'Update dependency @vitejs/plugin-react to ^5.1.4' (#183) from renovate/vitejs-plugin-react-5.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-14 00:28:11 +00:00
8b1356064e Merge pull request 'Update dependency @types/react to ^19.2.14' (#182) from renovate/react into master
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-14 00:28:09 +00:00
b0ceeba74b Update dependency @vitejs/plugin-react to ^5.1.4
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2026-02-13 00:28:13 +00:00
418bffe746 Update dependency @types/react to ^19.2.14
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2026-02-13 00:28:10 +00:00
b7c06eb1b6 Merge pull request 'Update dependency @mui/x-data-grid to ^8.27.0' (#181) from renovate/mui-x-data-grid-8.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-10 00:27:19 +00:00
7805412b07 Merge pull request 'Update Rust crate clap to 4.5.57' (#180) from renovate/clap-4.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-10 00:27:12 +00:00
d98854e4dd Update dependency @mui/x-data-grid to ^8.27.0
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2026-02-09 00:27:20 +00:00
8cd9da28e0 Update Rust crate clap to 4.5.57
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2026-02-09 00:27:18 +00:00
edfcbdd1f8 Merge pull request 'Update Rust crate anyhow to 1.0.101' (#179) from renovate/anyhow-1.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-08 00:27:18 +00:00
7c6520a2ee Merge pull request 'Update dependency @types/react to ^19.2.13' (#178) from renovate/react into master
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-08 00:27:15 +00:00
45609a2ae7 Update Rust crate anyhow to 1.0.101
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2026-02-07 00:27:41 +00:00
286a48b3b3 Update dependency @types/react to ^19.2.13
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2026-02-07 00:27:32 +00:00
054940716a Merge pull request 'Update Rust crate rust-embed to 8.11.0' (#177) from renovate/rust-embed-8.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-06 00:27:09 +00:00
0f374f7125 Merge pull request 'Update Rust crate bytes to 1.11.1' (#176) from renovate/bytes-1.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-06 00:27:03 +00:00
9e35af3341 Update Rust crate rust-embed to 8.11.0
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2026-02-05 00:27:27 +00:00
e864925f63 Update Rust crate bytes to 1.11.1
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2026-02-05 00:27:24 +00:00
f544946d06 Merge pull request 'Update dependency emoji-picker-react to ^4.17.4' (#175) from renovate/emoji-picker-react-4.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-04 00:26:54 +00:00
0671911d20 Merge pull request 'Update dependency @vitejs/plugin-react to ^5.1.3' (#174) from renovate/vitejs-plugin-react-5.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-04 00:26:50 +00:00
e5f5fe6e90 Update dependency emoji-picker-react to ^4.17.4
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2026-02-03 00:26:36 +00:00
37d31b42b3 Update dependency @vitejs/plugin-react to ^5.1.3
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2026-02-03 00:26:34 +00:00
474bacfd23 Merge pull request 'Update dependency typescript-eslint to ^8.54.0' (#173) from renovate/typescript-eslint into master
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-02 00:26:48 +00:00
893ff2cbbe Merge pull request 'Update dependency react-router to ^7.13.0' (#172) from renovate/react-router-7.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-02 00:26:41 +00:00
a738ab5525 Update dependency typescript-eslint to ^8.54.0
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2026-02-01 00:26:20 +00:00
78c35524d0 Update dependency react-router to ^7.13.0
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2026-02-01 00:26:16 +00:00
f566847809 Merge pull request 'Update Rust crate clap to 4.5.56' (#171) from renovate/clap-4.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-31 00:26:32 +00:00
d8e6090717 Merge pull request 'Update dependency emoji-picker-react to ^4.17.3' (#170) from renovate/emoji-picker-react-4.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-31 00:26:30 +00:00
6a26d9a4c0 Update Rust crate clap to 4.5.56
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2026-01-30 00:27:54 +00:00
c2bed332da Update dependency emoji-picker-react to ^4.17.3
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2026-01-30 00:27:46 +00:00
7d2aedef97 Merge pull request 'Update Node.js to v25' (#128) from renovate/node-25.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-30 00:27:42 +00:00
f12a2ca1ba Merge pull request 'Update Rust crate light-openid to 1.1.0' (#169) from renovate/light-openid-1.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-30 00:27:31 +00:00
f0d7fbf82c Update Node.js to v25
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2026-01-29 00:27:27 +00:00
5220f3e873 Update Rust crate light-openid to 1.1.0
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2026-01-29 00:27:25 +00:00
7e6f9bd648 Merge pull request 'Update react' (#168) from renovate/react into master
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-29 00:27:06 +00:00
e3e53abe34 Update react
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2026-01-28 00:27:11 +00:00
65ee2ac6dc Merge pull request 'Update Rust crate lazy-regex to 3.5.1' (#167) from renovate/lazy-regex-3.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-28 00:27:04 +00:00
8faa3592c4 Update Rust crate lazy-regex to 3.5.1
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2026-01-27 00:26:21 +00:00
690c305207 Merge pull request 'Update dependency emoji-picker-react to ^4.17.1' (#166) from renovate/emoji-picker-react-4.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-27 00:26:05 +00:00
5c8b7a2082 Update dependency emoji-picker-react to ^4.17.1
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2026-01-26 00:26:46 +00:00
9f9fd28b6a Merge pull request 'Update dependency is-cidr to ^6.0.2' (#165) from renovate/is-cidr-6.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-26 00:26:41 +00:00
3d98c276b0 Update dependency is-cidr to ^6.0.2
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2026-01-25 00:27:11 +00:00
aec1594a98 Merge pull request 'Update dependency @mui/x-date-pickers to ^8.26.0' (#164) from renovate/mui-x-date-pickers-8.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-25 00:27:06 +00:00
366238e9b8 Update dependency @mui/x-date-pickers to ^8.26.0
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2026-01-24 00:26:14 +00:00
888f629e8a Merge pull request 'Update dependency @mui/x-data-grid to ^8.26.0' (#163) from renovate/mui-x-data-grid-8.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-24 00:26:09 +00:00
924433ca40 Update dependency @mui/x-data-grid to ^8.26.0
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2026-01-23 00:26:13 +00:00
99b4f67275 Merge pull request 'Update Rust crate jwt-simple to 0.12.14' (#162) from renovate/jwt-simple-0.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-23 00:26:08 +00:00
a6ff89e80d Update Rust crate jwt-simple to 0.12.14
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2026-01-22 00:27:35 +00:00
6d3ba0ccae Merge pull request 'Update dependency @types/react to ^19.2.9' (#161) from renovate/react into master
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-22 00:27:21 +00:00
ec3377692d Update dependency @types/react to ^19.2.9
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2026-01-21 00:15:08 +00:00
574761d39b Merge pull request 'Update dependency date-and-time to ^4.2.0' (#160) from renovate/date-and-time-4.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-21 00:14:44 +00:00
09c5b9f187 Update dependency date-and-time to ^4.2.0
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2026-01-20 00:11:36 +00:00
11f03f88c6 Merge pull request 'Update Rust crate thiserror to 2.0.18' (#159) from renovate/thiserror-2.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-20 00:11:30 +00:00
2ebf17f065 Update Rust crate thiserror to 2.0.18
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2026-01-19 00:11:27 +00:00
b9bfe17314 Merge pull request 'Update dependency @mui/x-date-pickers to ^8.25.0' (#158) from renovate/mui-x-date-pickers-8.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-19 00:11:11 +00:00
f56c646596 Update dependency @mui/x-date-pickers to ^8.25.0
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2026-01-18 00:12:07 +00:00
697fcc481e Merge pull request 'Update dependency @mui/x-data-grid to ^8.25.0' (#157) from renovate/mui-x-data-grid-8.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-18 00:12:01 +00:00
2fbca2c411 Update dependency @mui/x-data-grid to ^8.25.0
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2026-01-17 00:11:35 +00:00
399b13fdbc Merge pull request 'Update dependency @types/node to ^24.10.9' (#156) from renovate/node-24.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-17 00:11:28 +00:00
166b860c1a Update dependency @types/node to ^24.10.9
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2026-01-16 00:27:29 +00:00
96b02dd73a Merge pull request 'Update Rust crate actix-ws to 0.3.1' (#155) from renovate/actix-ws-0.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-16 00:27:22 +00:00
5dd4aa6c0e Update Rust crate actix-ws to 0.3.1
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2026-01-15 00:32:56 +00:00
8f4480e555 Merge pull request 'Update dependency @types/node to ^24.10.8' (#154) from renovate/node-24.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-15 00:32:38 +00:00
06e1f60314 Update dependency @types/node to ^24.10.8
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2026-01-14 00:31:34 +00:00
a7432a4014 Merge pull request 'Update dependency @mui/x-data-grid to ^8.24.0' (#153) from renovate/mui-x-data-grid-8.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-14 00:31:25 +00:00
be5e7eb328 Update dependency @mui/x-data-grid to ^8.24.0
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2026-01-13 00:26:02 +00:00
c1e703c4b4 Merge pull request 'Update dependency @types/react to ^19.2.8' (#152) from renovate/react into master
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-13 00:25:55 +00:00
071aad8147 Update dependency @types/react to ^19.2.8
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2026-01-12 00:26:27 +00:00
f4b3c0aa16 Merge pull request 'Update dependency @types/node to ^24.10.7' (#151) from renovate/node-24.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-12 00:26:19 +00:00
daca7410d7 Update dependency @types/node to ^24.10.7
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2026-01-11 00:26:32 +00:00
23074ac354 Merge pull request 'Update dependency vite to v7.3.1' (#150) from renovate/vite-7.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-11 00:26:26 +00:00
36bd8d0672 Update dependency vite to v7.3.1
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2026-01-10 00:26:04 +00:00
6162555702 Merge pull request 'Update materialui to ^7.3.7' (#149) from renovate/materialui into master
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-10 00:25:57 +00:00
951d0db0b7 Update materialui to ^7.3.7
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2026-01-09 00:22:36 +00:00
ef90aba489 Merge pull request 'Update dependency react-router to ^7.12.0' (#148) from renovate/react-router-7.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-09 00:22:31 +00:00
6b39fd11bd Update dependency react-router to ^7.12.0
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2026-01-08 00:22:30 +00:00
bf6561fa87 Merge pull request 'Update Rust crate serde_json to 1.0.149' (#147) from renovate/serde_json-1.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-08 00:22:26 +00:00
e5feecc703 Update Rust crate serde_json to 1.0.149
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2026-01-07 00:22:38 +00:00
153ad14a51 Merge pull request 'Update Rust crate url to 2.5.8' (#146) from renovate/url-2.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-07 00:22:23 +00:00
e5494e51a3 Update Rust crate url to 2.5.8
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2026-01-06 00:22:56 +00:00
f04ab4591b Merge pull request 'Update Rust crate clap to 4.5.54' (#145) from renovate/clap-4.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-06 00:22:43 +00:00
ca2cdb2f79 Update Rust crate clap to 4.5.54
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2026-01-05 00:23:14 +00:00
795a12c8d0 Merge pull request 'Update dependency vite to v7.3.0' (#144) from renovate/vite-7.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-05 00:23:04 +00:00
79e78006fa Update dependency vite to v7.3.0
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-12-31 00:30:47 +00:00
18c0fbef3c Merge pull request 'Update dependency typescript-eslint to ^8.51.0' (#143) from renovate/typescript-eslint into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-31 00:30:41 +00:00
0ac6fc4ac3 Update dependency typescript-eslint to ^8.51.0
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-12-30 00:32:39 +00:00
2aaced17d8 Merge pull request 'Update dependency react-router to ^7.11.0' (#142) from renovate/react-router-7.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-30 00:32:13 +00:00
9da2a9e9b3 Update dependency react-router to ^7.11.0
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-12-29 00:32:02 +00:00
9595ff2e71 Merge pull request 'Update Rust crate serde_json to 1.0.148' (#141) from renovate/serde_json-1.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-29 00:31:44 +00:00
5bd62d7683 Update Rust crate serde_json to 1.0.148
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-12-28 00:30:14 +00:00
7e747b50f3 Merge pull request 'Update dependency @mui/x-date-pickers to ^8.23.0' (#140) from renovate/mui-x-date-pickers-8.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-28 00:29:59 +00:00
a9a5d60edd Update dependency @mui/x-date-pickers to ^8.23.0
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-12-27 00:31:20 +00:00
89dbc252e8 Merge pull request 'Update dependency @mui/x-data-grid to ^8.23.0' (#139) from renovate/mui-x-data-grid-8.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-27 00:31:12 +00:00
3a6b2c6cf2 Update dependency @mui/x-data-grid to ^8.23.0
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-12-25 00:33:13 +00:00
98c813b220 Merge pull request 'Update Rust crate serde_json to 1.0.147' (#138) from renovate/serde_json-1.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-25 00:33:00 +00:00
ceb7859169 Update Rust crate serde_json to 1.0.147
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-12-24 00:30:52 +00:00
96e597ca59 Merge pull request 'Update dependency @mui/x-date-pickers to ^8.22.1' (#137) from renovate/mui-x-date-pickers-8.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-24 00:30:43 +00:00
d9630fbc4c Update dependency @mui/x-date-pickers to ^8.22.1
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-12-23 00:30:42 +00:00
4d7db2de2a Merge pull request 'Update dependency @mui/x-data-grid to ^8.22.1' (#136) from renovate/mui-x-data-grid-8.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-23 00:30:22 +00:00
4c0be88570 Update dependency @mui/x-data-grid to ^8.22.1
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-12-22 00:32:09 +00:00
2f933a247f Merge pull request 'Update Rust crate ractor to 0.15.10' (#135) from renovate/ractor-0.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-22 00:31:59 +00:00
f6a7132d43 Update Rust crate ractor to 0.15.10
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-12-21 00:30:59 +00:00
1089b5a6a6 Merge pull request 'Update react to ^19.2.3' (#134) from renovate/react into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-21 00:30:45 +00:00
28a1b5f4f0 Update react to ^19.2.3
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-12-20 00:29:35 +00:00
acf91c3f0e Merge pull request 'Update dependency eslint-plugin-react-refresh to ^0.4.26' (#133) from renovate/eslint-plugin-react-refresh-0.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-20 00:29:29 +00:00
fadb9e6d46 Update dependency eslint-plugin-react-refresh to ^0.4.26
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-12-19 00:29:43 +00:00
17bad4fcfd Merge pull request 'Update dependency eslint to ^9.39.2' (#132) from renovate/eslint-9.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-19 00:29:35 +00:00
b3dfc35103 Update dependency eslint to ^9.39.2
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-12-18 00:32:10 +00:00
602f663217 Merge pull request 'Update dependency date-and-time to ^4.1.2' (#131) from renovate/date-and-time-4.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-18 00:31:58 +00:00
5ebfbf6aec Update dependency date-and-time to ^4.1.2
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-12-17 00:30:33 +00:00
aad0a74ad5 Merge pull request 'Update dependency @types/node to ^24.10.4' (#130) from renovate/node-24.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-17 00:30:28 +00:00
382e24e17b Update dependency @types/node to ^24.10.4
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-12-16 00:30:32 +00:00
1876c7b43d Merge pull request 'Update dependency @eslint/js to ^9.39.2' (#129) from renovate/eslint-js-9.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-16 00:30:25 +00:00
73af601a16 Update dependency @eslint/js to ^9.39.2
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-12-13 00:29:49 +00:00
6247463c70 Merge pull request 'Update dependency @types/node to ^24.10.3' (#127) from renovate/node-24.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-12 00:29:56 +00:00
9425ed9a12 Update dependency @types/node to ^24.10.3
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is passing
2025-12-11 00:30:39 +00:00
430ad85c37 Merge pull request 'Update dependency @vitejs/plugin-react to ^5.1.2' (#126) from renovate/vitejs-plugin-react-5.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-11 00:30:33 +00:00
29e50bd70c Update dependency @vitejs/plugin-react to ^5.1.2
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is failing
2025-12-10 00:32:04 +00:00
0e83e804d8 Merge pull request 'Update dependency @types/node to ^24.10.2' (#125) from renovate/node-24.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-10 00:31:56 +00:00
bd674bfb67 Update dependency @types/node to ^24.10.2
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-12-09 00:30:40 +00:00
fec81ac92e Remove encryption logic as it is handled by matrix-sdk e2e-encryption feature directly
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-08 19:41:40 +01:00
d3e25eed9e Merge pull request 'Update Rust crate matrix-sdk to 0.16.0' (#124) from renovate/matrix-sdk-0.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-08 00:31:02 +00:00
ba5f5f2557 Update Rust crate matrix-sdk to 0.16.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-12-08 00:30:55 +00:00
7e548ad5d1 Merge pull request 'Update react to ^19.2.1' (#123) from renovate/react into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-08 00:30:43 +00:00
7b63bb0d05 Update react to ^19.2.1
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is failing
2025-12-07 00:31:00 +00:00
788018451a Merge pull request 'Update dependency react-router to ^7.10.1' (#122) from renovate/react-router-7.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-07 00:30:53 +00:00
7b3a2d6a3f Update dependency react-router to ^7.10.1
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-12-06 00:30:35 +00:00
0d462f848d Merge pull request 'Update dependency eslint-plugin-react-refresh to ^0.4.24' (#121) from renovate/eslint-plugin-react-refresh-0.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-06 00:30:30 +00:00
9c6c338919 Update dependency eslint-plugin-react-refresh to ^0.4.24
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-12-05 00:15:23 +00:00
8a4570a044 Fix Drone configuration
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-12-04 17:23:06 +01:00
e51fc6b4bb Fix ESLint issues
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is failing
2025-12-04 16:32:06 +01:00
0f68d59798 Fix spaces support in UI
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-04 15:35:16 +01:00
5ad23005be Fix time alignment
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-04 15:03:38 +01:00
4e096a1d49 Can get spaces hierarchy
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-04 09:18:32 +01:00
ac2a361b77 Can filter rooms list
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-04 08:53:11 +01:00
24f8d67020 Fix bug in screen 2025-12-04 08:36:48 +01:00
5bcee2ea9d Reduce the number of loaded messages per conversations
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-03 20:39:26 +01:00
48d9444dde Add a button to manually load older messages
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-03 20:18:11 +01:00
bcdfe87107 Add reply support through WebSocket
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-03 19:22:53 +01:00
5088699c15 Add replies support
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-03 19:20:17 +01:00
854b474970 Fix lock file
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-03 16:28:21 +01:00
336aea463b Merge branch 'migrate-to-matrix-sdk'
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-03 16:22:49 +01:00
fe9c692e12 Fix alignment inside WSDebugRoute
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is passing
2025-12-03 16:18:03 +01:00
b47ec37a76 Remove unused imports in login route
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is passing
2025-12-03 16:12:00 +01:00
996534c62b Fix emoji size
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2025-12-03 16:10:56 +01:00
3ba6543cb4 Remove @mdi/js as a dependency
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2025-12-03 16:07:34 +01:00
f087b27b53 Updated frontend dependencies
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is passing
2025-12-03 15:13:01 +01:00
dfcf764a9b Updated backend dependencies 2025-12-03 15:00:58 +01:00
fb35fca56e Fix build issues
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is passing
2025-12-03 14:53:06 +01:00
f6568cf059 Fix ESLint issues
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2025-12-03 14:38:58 +01:00
bbf558bbf9 WIP ESLint fixes
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2025-12-03 11:16:14 +01:00
1090a59aaf Quick ESLint issues fix
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2025-12-03 09:48:03 +01:00
30518f3ca3 Fix Web build
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2025-12-03 09:46:51 +01:00
e215fe6484 Add Renovate config
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-12-03 09:45:46 +01:00
6392c0a2c7 Add production Makefile 2025-12-03 09:43:04 +01:00
4110f4d063 Can load older messages in conversations 2025-12-03 09:11:02 +01:00
1a5a021711 Can filter to show only unread rooms 2025-12-03 08:53:44 +01:00
8b299bcf8f Merge pull request 'Update Rust crate serde_json to 1.0.145' (#94) from renovate/serde_json-1.x into master
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-03 00:15:17 +00:00
ab136ef6d0 Display a message when the conversation is empty 2025-12-02 13:59:14 +01:00
1fa98cf6e3 Update Rust crate serde_json to 1.0.145
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-12-02 00:14:39 +00:00
118b73fce9 Improve receipts spacing 2025-12-01 19:32:50 +01:00
95fb095205 Switch to native emojies 2025-12-01 19:29:18 +01:00
3274d07635 Do not display spaces bar if it is useless 2025-12-01 19:06:58 +01:00
6d78930b89 Add link on unlinked account message 2025-12-01 19:05:37 +01:00
7356a66e4a Handle read receipts on web ui 2025-12-01 18:30:22 +01:00
30e63bfdb4 Handle typing events 2025-12-01 17:23:15 +01:00
e80d54d0e7 Add auto-release configuration
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-12-01 11:27:13 +01:00
b91b61f4f0 Downgrade ruma
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-01 11:24:36 +01:00
32354f79ea Add typing event definition 2025-12-01 11:18:58 +01:00
077c64be28 Forward typing event in WebSocket 2025-12-01 11:17:02 +01:00
dac20f60e0 Can get read receipts 2025-12-01 11:09:14 +01:00
9359dc5be0 Send read receipts 2025-12-01 10:42:19 +01:00
849aef9343 Update unread messages count only if room is not muted 2025-12-01 10:30:54 +01:00
196671d0fb Remove unread marker when receiving proper read receipt 2025-12-01 10:25:14 +01:00
b93100413c Propagate read receipt events 2025-12-01 10:12:11 +01:00
57797e933a Can check if rooms are muted 2025-12-01 09:12:58 +01:00
7acb0cbafa Display WS state in favicon 2025-12-01 08:44:30 +01:00
64985bb39e Display application icon 2025-12-01 08:36:29 +01:00
1f22d5c41b Refactor rooms management 2025-11-28 18:41:43 +01:00
a656c077bc Follow unread messages 2025-11-28 18:06:40 +01:00
d10c4d1a1c Minor appearance improvement 2025-11-28 17:39:27 +01:00
62966473f0 Add support for more file formats 2025-11-28 17:37:30 +01:00
c360432911 Add support for unencrypted media 2025-11-28 17:22:58 +01:00
123e069d18 Add multi line messages supports 2025-11-28 17:15:33 +01:00
4b30d67706 Basic WS sync 2025-11-28 17:00:42 +01:00
799341f77c Display the list of people who reacted 2025-11-28 14:42:02 +01:00
6c11979ef2 Display reactions below messages 2025-11-28 14:37:17 +01:00
9f0bc3303c Add quick reactions 2025-11-28 12:05:29 +01:00
f5b16b6ce4 Can send reaction from picker 2025-11-28 11:41:19 +01:00
756780513b Fix dialog name 2025-11-28 10:41:30 +01:00
93487a5325 Can edit message content 2025-11-28 10:41:01 +01:00
3d27279a16 Can delete events from WebUI 2025-11-28 10:22:44 +01:00
94ce9c3c95 Add buttons bar 2025-11-28 09:55:21 +01:00
9f83a6fb66 Can send text messages in conversations 2025-11-27 19:13:16 +01:00
bda47a2770 Improve messages appearance 2025-11-27 18:55:30 +01:00
f0e8c799ff Merge pull request 'Update Rust crate actix-web to 4.12.1' (#119) from renovate/actix-web-4.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-27 00:16:23 +00:00
b4e7cb8718 Update Rust crate actix-web to 4.12.1
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-11-27 00:16:15 +00:00
b7378aa4dc Can retrieve room media 2025-11-25 14:54:02 +01:00
2adbf146d0 Start to display messages list 2025-11-25 12:17:48 +01:00
5eab7c3e4f Process events list client side 2025-11-25 09:48:49 +01:00
a7bfd713c3 Ready to implement room widget 2025-11-24 17:59:12 +01:00
4be661d999 Fix appearance of unread conversations 2025-11-24 17:55:26 +01:00
1f4e374e66 Display rooms list 2025-11-24 17:50:31 +01:00
cce9b3de5d Hide menu by default on desktop 2025-11-24 16:36:36 +01:00
820b095be0 Display the list of spaces 2025-11-24 16:05:01 +01:00
0a37688116 Can react to event 2025-11-24 13:40:14 +01:00
4d72644a31 Can edit message 2025-11-24 13:18:23 +01:00
0a395b0d26 Can redact message 2025-11-24 13:06:31 +01:00
639cc6c737 Can send text message 2025-11-24 12:54:59 +01:00
bf119a34fb Can get room messages 2025-11-24 12:36:59 +01:00
7562a7fc61 Get latest message for a room 2025-11-24 11:20:20 +01:00
d23190f9d2 Can get spaces of user 2025-11-21 18:38:20 +01:00
35b53fee5c Can request any media file 2025-11-21 17:55:09 +01:00
934e6a4cc1 Can get multiple profiles information 2025-11-21 17:49:41 +01:00
b744265242 Can get single profile information 2025-11-21 17:14:23 +01:00
e8ce97eea0 Can get room avatar 2025-11-21 15:43:15 +01:00
ecbe4885c1 Can get information about rooms 2025-11-21 14:52:21 +01:00
1385afc974 Add more information to websocket messages 2025-11-21 11:40:51 +01:00
8d2cea5f82 Refactor messages propagation 2025-11-21 10:30:48 +01:00
751e3b8654 Redact more events 2025-11-21 09:35:21 +01:00
24f06a78a9 Block WS access if Matrix account is not linked 2025-11-21 09:12:13 +01:00
7a590e882b Merge pull request 'Update Rust crate ruma to 0.14.0' (#118) from renovate/ruma-0.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-21 00:12:11 +00:00
9a643ced94 Update Rust crate ruma to 0.14.0
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-11-21 00:11:59 +00:00
6b70842b61 Display state in color 2025-11-20 19:31:17 +01:00
7203671b18 Pretty rendering of JSON messages 2025-11-20 19:30:09 +01:00
055ab3759c Remove frontend messages 2025-11-20 19:15:42 +01:00
3ecfc6b470 Add base debug WS route 2025-11-20 19:14:02 +01:00
a1b22699e9 Basic implementation of websocket 2025-11-20 16:06:00 +01:00
5f2a6478a7 Merge pull request 'Update Rust crate clap to 4.5.53' (#117) from renovate/clap-4.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-20 00:09:04 +00:00
1db929a31b Update Rust crate clap to 4.5.53
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-11-20 00:08:50 +00:00
0d8905d842 Can stop sync thread from UI 2025-11-19 18:41:26 +01:00
564e606ac7 Properly handle start sync thread issue 2025-11-19 17:15:54 +01:00
7b691962a0 Can get sync thread status 2025-11-19 16:34:00 +01:00
1e00d24a8b Can request sync thread stop 2025-11-19 15:51:15 +01:00
cfdf98b47a Matrix messages are broadcasted 2025-11-19 14:02:51 +01:00
75b6b224bc Notify Matrix manager directly if sync thread is terminated 2025-11-19 13:39:28 +01:00
07f6544a4a WIP sync thread implementation 2025-11-19 11:37:57 +01:00
5bf7c7f8df Do not start sync thread if user is disconnected 2025-11-19 10:49:26 +01:00
79d4482ea4 Sync threads can be interrupted 2025-11-19 10:27:46 +01:00
c9b703bea3 Ready to implement sync thread logic 2025-11-18 22:17:39 +01:00
5c13cffe08 Send broadcast message when an API token is deleted 2025-11-18 15:09:27 +01:00
b5832df746 Can delete API token from UI 2025-11-18 14:51:05 +01:00
0b2c4071e8 Merge pull request 'Update Rust crate clap to 4.5.52' (#116) from renovate/clap-4.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-18 00:10:34 +00:00
61ecfc5af1 Update Rust crate clap to 4.5.52
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-11-18 00:10:24 +00:00
661793f58d Merge pull request 'Update Rust crate actix-web to 4.12.0' (#115) from renovate/actix-web-4.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-17 00:09:32 +00:00
d253e73099 Update Rust crate actix-web to 4.12.0
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-11-17 00:09:22 +00:00
f0d3d311e9 Merge pull request 'Update Rust crate bytes to 1.11.0' (#114) from renovate/bytes-1.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-15 00:09:38 +00:00
592203aa4a Update Rust crate bytes to 1.11.0
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2025-11-15 00:09:30 +00:00
02e5575892 Display the list of API tokens 2025-11-14 09:07:22 +01:00
2683268042 Load the list of API tokens 2025-11-13 21:16:45 +01:00
72aaf7b082 Add token creation dialog 2025-11-13 21:03:38 +01:00
c8a48488fc Fix session disconnection issue by removing automatic refresh on client initialization 2025-11-13 18:38:27 +01:00
3b7b368e13 Attempt to fix session restoration issues 2025-11-12 08:14:16 +01:00
5ca126eef7 Split recovery key dialog in new file 2025-11-12 08:06:47 +01:00
7c78eb541e Fix example API client 2025-11-11 21:24:19 +01:00
8fdf1d57eb Create & list tokens 2025-11-11 21:19:54 +01:00
b10ec9ce92 Cleanup code 2025-11-11 17:58:24 +01:00
7925785c8b Fix issue 2025-11-11 17:19:23 +01:00
84c90ea033 Can set user recovery key from UI 2025-11-10 17:42:32 +01:00
a23d671376 Can set recovery key of user 2025-11-10 08:47:02 +01:00
4a72411d65 Return encryption recovery status on API 2025-11-10 08:32:17 +01:00
70a246355b Can disconnect user from UI 2025-11-06 21:33:09 +01:00
8bbbe7022f Automatically disconnect user when token is invalid 2025-11-06 21:18:27 +01:00
1ba5372468 Restore user session on restart 2025-11-06 18:58:43 +01:00
aeb35029c3 Merge pull request 'Update Rust crate sha2 to 0.11.0-rc.3' (#113) from renovate/sha2-0.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-06 00:11:57 +00:00
1dc56d5ec1 Update Rust crate sha2 to 0.11.0-rc.3
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-11-06 00:11:50 +00:00
1438e2de0e Can save Matrix session after authentication 2025-11-05 22:53:55 +01:00
1eaec9d319 Can finalize Matrix authentication 2025-11-05 19:32:11 +01:00
51b1ab380c Merge pull request 'Update Rust crate rust-embed to 8.9.0' (#112) from renovate/rust-embed-8.x into master
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-03 00:09:55 +00:00
b5abddaacb Update Rust crate rust-embed to 8.9.0
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2025-11-03 00:09:54 +00:00
114 changed files with 8693 additions and 2234 deletions

104
.drone.yml Normal file
View File

@@ -0,0 +1,104 @@
---
kind: pipeline
type: docker
name: default
steps:
# Frontend
- name: web_build
image: node:25
volumes:
- name: web_app
path: /tmp/web_build
commands:
- node -v
- npm -v
- cd matrixgw_frontend
- npm install
- npm run lint
- npm run build
- mv dist /tmp/web_build
# Backend
- name: backend_fetch_deps
image: rust
volumes:
- name: rust_registry
path: /usr/local/cargo/registry
commands:
- cd matrixgw_backend
- cargo fetch
- name: backend_code_quality
image: rust
volumes:
- name: rust_registry
path: /usr/local/cargo/registry
depends_on:
- backend_fetch_deps
commands:
- cd matrixgw_backend
- rustup component add clippy
- cargo clippy -- -D warnings
- cargo clippy --example api_curl -- -D warnings
- name: backend_test
image: rust
volumes:
- name: rust_registry
path: /usr/local/cargo/registry
depends_on:
- backend_code_quality
commands:
- cd matrixgw_backend
- cargo test
- name: backend_build
image: rust
when:
event:
- tag
volumes:
- name: rust_registry
path: /usr/local/cargo/registry
- name: web_app
path: /tmp/web_build
- name: release
path: /tmp/release
depends_on:
- backend_test
- web_build
commands:
- cd matrixgw_backend
- mv /tmp/web_build/dist static
- cargo build --release
- cargo build --release --example api_curl
- ls -lah target/release/matrixgw_backend target/release/examples/api_curl
- cp target/release/matrixgw_backend target/release/examples/api_curl /tmp/release
# Release
- name: gitea_release
image: plugins/gitea-release
depends_on:
- backend_build
when:
event:
- tag
volumes:
- name: release
path: /tmp/release
environment:
PLUGIN_API_KEY:
from_secret: GITEA_API_KEY # needs permission write:repository
settings:
base_url: https://gitea.communiquons.org
files: /tmp/release/*
checksum: sha512
volumes:
- name: rust_registry
temp: {}
- name: web_app
temp: {}
- name: release
temp: {}

18
Makefile Normal file
View File

@@ -0,0 +1,18 @@
DOCKER_TEMP_DIR=temp
all: frontend backend
frontend:
cd matrixgw_frontend && npm run build && cd ..
rm -rf matrixgw_backend/static
mv matrixgw_frontend/dist matrixgw_backend/static
backend: frontend
cd matrixgw_backend && cargo clippy -- -D warnings && cargo build --release
backend_docker: backend
rm -rf $(DOCKER_TEMP_DIR)
mkdir $(DOCKER_TEMP_DIR)
cp matrixgw_backend/target/release/matrixgw_backend $(DOCKER_TEMP_DIR)
docker build -t pierre42100/matrix_gateway -f matrixgw_backend/docker/matrixgw_backend/Dockerfile "$(DOCKER_TEMP_DIR)"
rm -rf $(DOCKER_TEMP_DIR)

6
docker_prod/.env.sample Normal file
View File

@@ -0,0 +1,6 @@
REDIS_PASS=redis_password
WEBSITE_ORIGIN=http://localhost:8000
APP_SECRET=secretsecretsecretsecretsecretsecretsecretsecretsecretsecretsecret
AUTH_SECRET_KEY=secretsecretsecretsecretsecretsecretsecretsecretsecretsecretsecret
OIDC_CLIENT_ID=bar
OIDC_CLIENT_SECRET=foo

3
docker_prod/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.env
storage
auth/users.json

44
docker_prod/README.md Normal file
View File

@@ -0,0 +1,44 @@
# Setup production environment
> Sample release deployment configuration. **MUST BE ADAPTED BEFORE REAL PRODUCTION DEPLOYMENT!**
1. Install prerequisites:
1. `docker`
2. `docker compose`
3. `git`
2. Clone this git repository:
```bash
git clone https://gitea.communiquons.org/pierre/MatrixGW
cd MatrixGW/docker_prod
```
3. Copy and adapt env values
```bash
cp .env.sample .env
nano .env
```
4. Create required directories:
```bash
mkdir -p storage/{redis-data,redis-conf,synapse,maspostgres,matrixgw}
```
5. Start containers
```bash
docker compose up
```
> Note: Before running `docker compose up`, if your user does not belong to the `docker` group, you should run the following command to be able to run docker in rootless mode:
>
> ```bash
> sudo -g docker bash
> ```
6. Done !
* Matrix GW: http://localhost:8000/, the default credentials are `admin` / `admin`
* Element: http://localhost:8080 (you will need to create your accounts)
* Auth platform: http://localhost:5001

View File

@@ -0,0 +1,5 @@
- id: ${OIDC_CLIENT_ID}
name: MatrixGW
description: Matrix Gateway
secret: ${OIDC_CLIENT_SECRET}
redirect_uri: ${APP_ORIGIN}/oidc_cb

View File

@@ -0,0 +1,102 @@
services:
oidc:
image: pierre42100/basic_oidc
user: "1000"
environment:
- LISTEN_ADDRESS=0.0.0.0:9001
- STORAGE_PATH=/storage
- TOKEN_KEY=$AUTH_SECRET_KEY
- WEBSITE_ORIGIN=http://localhost:9001
- OIDC_CLIENT_ID=$OIDC_CLIENT_ID
- OIDC_CLIENT_SECRET=$OIDC_CLIENT_SECRET
- APP_ORIGIN=$WEBSITE_ORIGIN
expose:
- 9001
ports:
- 9001:9001
volumes:
- ./auth:/storage
redis:
image: redis:alpine
user: "1000"
command: redis-server --requirepass ${REDIS_PASS:-secretredis}
expose:
- 6379
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 3
volumes:
- ./storage/redis-data:/data
- ./storage/redis-conf:/usr/local/etc/redis/redis.conf
mas:
image: ghcr.io/element-hq/matrix-authentication-service:main
user: "1000"
restart: unless-stopped
depends_on:
- masdb
volumes:
- ./mas:/config:ro
command: server -c /config/config.yaml
ports:
- "8778:8778/tcp"
synapse:
image: docker.io/matrixdotorg/synapse:latest
user: "1000"
restart: unless-stopped
environment:
- SYNAPSE_CONFIG_PATH=/config/homeserver.yaml
volumes:
- ./storage/synapse:/data
- ./synapse:/config:ro
ports:
- "8448:8448/tcp"
masdb:
image: docker.io/postgres:18-alpine
user: "1000"
environment:
- POSTGRES_DB=masdb
- POSTGRES_USER=masdb
- POSTGRES_PASSWORD=changeme
- POSTGRES_INITDB_ARGS=--encoding=UTF-8 --lc-collate=C --lc-ctype=C
- PGDATA=/data
volumes:
- ./storage/maspostgres:/data
element:
image: docker.io/vectorim/element-web
ports:
- "8080:80/tcp"
volumes:
- ./element/config.json:/app/config.json:ro
matrixgw:
image: pierre42100/matrix_gateway
user: "1000"
ports:
- 8000:8000
depends_on:
redis:
condition: service_healthy
volumes:
- ./storage/matrixgw:/data
network_mode: host
environment:
- WEBSITE_ORIGIN=${WEBSITE_ORIGIN}
- SECRET=${APP_SECRET}
- OIDC_CONFIGURATION_URL=http://localhost:9001/.well-known/openid-configuration
- OIDC_PROVIDER_NAME=OIDC
- OIDC_CLIENT_ID=$OIDC_CLIENT_ID
- OIDC_CLIENT_SECRET=$OIDC_CLIENT_SECRET
- REDIS_HOSTNAME=localhost #redis
- REDIS_PASSWORD=${REDIS_PASS:-secretredis}
- UNSECURE_AUTO_LOGIN_EMAIL=$UNSECURE_AUTO_LOGIN_EMAIL
- STORAGE_PATH=/data
- MATRIX_HOMESERVER=http://localhost:8448

View File

@@ -0,0 +1,49 @@
{
"default_server_config": {
"m.homeserver": {
"base_url": "http://localhost:8448",
"server_name": "devserver"
},
"m.identity_server": {
"base_url": "https://vector.im"
}
},
"disable_custom_urls": false,
"disable_guests": false,
"disable_login_language_selector": false,
"disable_3pid_login": false,
"brand": "Element",
"integrations_ui_url": "https://scalar.vector.im/",
"integrations_rest_url": "https://scalar.vector.im/api",
"integrations_widgets_urls": [
"https://scalar.vector.im/_matrix/integrations/v1",
"https://scalar.vector.im/api",
"https://scalar-staging.vector.im/_matrix/integrations/v1",
"https://scalar-staging.vector.im/api",
"https://scalar-staging.riot.im/scalar/api"
],
"default_country_code": "GB",
"show_labs_settings": false,
"features": {},
"default_federate": true,
"default_theme": "light",
"room_directory": {
"servers": ["matrix.org"]
},
"enable_presence_by_hs_url": {
"https://matrix.org": false,
"https://matrix-client.matrix.org": false
},
"setting_defaults": {
"breadcrumbs": true
},
"jitsi": {
"preferred_domain": "meet.element.io"
},
"element_call": {
"url": "https://call.element.io",
"participant_limit": 8,
"brand": "Element Call"
},
"map_style_url": "https://api.maptiler.com/maps/streets/style.json?key=fU3vlMsMn4Jb6dnEIFsx"
}

113
docker_prod/mas/config.yaml Normal file
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

@@ -0,0 +1,41 @@
# Configuration file for Synapse.
#
# This is a YAML file: see [1] for a quick introduction. Note in particular
# that *indentation is important*: all the elements of a list or dictionary
# should have the same indentation.
#
# [1] https://docs.ansible.com/ansible/latest/reference_appendices/YAMLSyntax.html
#
# For more information on how to configure Synapse, including a complete accounting of
# each option, go to docs/usage/configuration/config_documentation.md or
# https://element-hq.github.io/synapse/latest/usage/configuration/config_documentation.html
server_name: "localhost"
pid_file: /data/homeserver.pid
listeners:
- port: 8448
tls: false
type: http
x_forwarded: true
resources:
- names: [client, federation]
compress: false
database:
name: sqlite3
args:
database: /data/homeserver.db
log_config: "/config/localhost.log.config"
media_store_path: /data/media_store
registration_shared_secret: "+oJd9zgvkQpXN-tt;95Wy,AFAdRH+FSTg&LxUXh6ZSvwMJHT;h"
report_stats: false
macaroon_secret_key: "d@ck1QkQLxlRg^aB#c#oZeII.oxOS6E2DX;YobP^Vm#iB5pQpd"
form_secret: "P.uleBJUYc6AM.UOrFF1q7OKH2N5T*Ae2;fGh46;vIHLIQ#JBP"
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

@@ -0,0 +1,39 @@
version: 1
formatters:
precise:
format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s'
handlers:
console:
class: logging.StreamHandler
formatter: precise
loggers:
# This is just here so we can leave `loggers` in the config regardless of whether
# we configure other loggers below (avoid empty yaml dict error).
_placeholder:
level: "INFO"
synapse.storage.SQL:
# beware: increasing this to DEBUG will make synapse log sensitive
# information such as access tokens.
level: INFO
root:
level: INFO
handlers: [console]
disable_existing_loggers: false

View File

@@ -0,0 +1 @@
ed25519 a_HEcG Q2iG1Yy5WTiZ/VIy+zHPyHCRUpqyE3qrVttGULrVQK4

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -4,30 +4,36 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
env_logger = "0.11.8" env_logger = "0.11.9"
log = "0.4.28" log = "0.4.29"
clap = { version = "4.5.51", features = ["derive", "env"] } clap = { version = "4.5.60", features = ["derive", "env"] }
lazy_static = "1.5.0" anyhow = "1.0.102"
anyhow = "1.0.100"
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
tokio = { version = "1.48.0", features = ["full"] } tokio = { version = "1.50.0", features = ["full"] }
actix-web = "4.11.0" actix-web = "4.13.0"
actix-session = { version = "0.11.0", features = ["redis-session"] } actix-session = { version = "0.11.0", features = ["redis-session"] }
actix-remote-ip = "0.1.0" actix-remote-ip = "0.1.0"
actix-cors = "0.7.1" actix-cors = "0.7.1"
light-openid = "1.0.4" actix-multipart = "0.7.2"
bytes = "1.10.1" light-openid = "1.1.0"
sha2 = "0.10.9" bytes = "1.11.1"
urlencoding = "2.1.3" sha2 = "0.11.0-rc.5"
base16ct = { version = "0.3.0", features = ["alloc"] } base16ct = { version = "1.0.0", features = ["alloc"] }
futures-util = "0.3.31" futures-util = "0.3.32"
jwt-simple = { version = "0.12.13", default-features = false, features = ["pure-rust"] } jwt-simple = { version = "0.12.14", default-features = false, features = ["pure-rust"] }
thiserror = "2.0.17" thiserror = "2.0.18"
uuid = { version = "1.18.1", features = ["v4", "serde"] } uuid = { version = "1.21.0", features = ["v4", "serde"] }
ipnet = { version = "2.11.0", features = ["serde"] } ipnet = { version = "2.11.0", features = ["serde"] }
rand = "0.9.2" rand = "0.10.0"
hex = "0.4.3" hex = "0.4.3"
mailchecker = "6.0.19" mailchecker = "6.0.19"
matrix-sdk = "0.14.0" matrix-sdk = { version = "0.16.0", features = ["e2e-encryption"] }
url = "2.5.7" matrix-sdk-ui = "0.16.0"
ractor = "0.15.9" url = "2.5.8"
ractor = "0.15.10"
serde_json = "1.0.149"
lazy-regex = "3.6.0"
actix-ws = "0.4.0"
infer = "0.19.0"
rust-embed = "8.11.0"
mime_guess = "2.0.5"

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ use matrix_sdk::authentication::oauth::registration::{
ApplicationType, ClientMetadata, Localized, OAuthGrantType, ApplicationType, ClientMetadata, Localized, OAuthGrantType,
}; };
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use url::Url; use url::Url;
/// Matrix gateway backend API /// Matrix gateway backend API
@@ -89,16 +90,12 @@ pub struct AppConfig {
storage_path: String, storage_path: String,
} }
lazy_static::lazy_static! { static ARGS: OnceLock<AppConfig> = OnceLock::new();
static ref ARGS: AppConfig = {
AppConfig::parse()
};
}
impl AppConfig { impl AppConfig {
/// Get parsed command line arguments /// Get parsed command line arguments
pub fn get() -> &'static AppConfig { pub fn get() -> &'static AppConfig {
&ARGS ARGS.get_or_init(AppConfig::parse)
} }
/// Get auto login email (if not empty) /// Get auto login email (if not empty)
@@ -220,6 +217,11 @@ impl AppConfig {
pub fn user_matrix_passphrase_path(&self, mail: &UserEmail) -> PathBuf { pub fn user_matrix_passphrase_path(&self, mail: &UserEmail) -> PathBuf {
self.user_directory(mail).join("matrix-db-passphrase") self.user_directory(mail).join("matrix-db-passphrase")
} }
/// Get user Matrix session file path
pub fn user_matrix_session_file_path(&self, mail: &UserEmail) -> PathBuf {
self.user_directory(mail).join("matrix-session.json")
}
} }
#[derive(Debug, Clone, serde::Serialize)] #[derive(Debug, Clone, serde::Serialize)]

View File

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

View File

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

View File

@@ -1,8 +1,10 @@
use crate::app_config::AppConfig; use crate::app_config::AppConfig;
use crate::controllers::{HttpFailure, HttpResult}; use crate::broadcast_messages::BroadcastSender;
use crate::controllers::HttpResult;
use crate::extractors::auth_extractor::{AuthExtractor, AuthenticatedMethod}; use crate::extractors::auth_extractor::{AuthExtractor, AuthenticatedMethod};
use crate::extractors::matrix_client_extractor::MatrixClientExtractor;
use crate::extractors::session_extractor::MatrixGWSession; use crate::extractors::session_extractor::MatrixGWSession;
use crate::users::{ExtendedUserInfo, User, UserEmail}; use crate::users::{User, UserEmail};
use actix_remote_ip::RemoteIP; use actix_remote_ip::RemoteIP;
use actix_web::{HttpResponse, web}; use actix_web::{HttpResponse, web};
use light_openid::primitives::OpenIDConfig; use light_openid::primitives::OpenIDConfig;
@@ -61,9 +63,7 @@ pub async fn finish_oidc(
let prov = AppConfig::get().openid_provider(); let prov = AppConfig::get().openid_provider();
let conf = OpenIDConfig::load_from_url(prov.configuration_url) let conf = OpenIDConfig::load_from_url(prov.configuration_url).await?;
.await
.map_err(HttpFailure::OpenID)?;
let (token, _) = conf let (token, _) = conf
.request_token( .request_token(
@@ -72,12 +72,8 @@ pub async fn finish_oidc(
&req.code, &req.code,
&AppConfig::get().openid_provider().redirect_url, &AppConfig::get().openid_provider().redirect_url,
) )
.await .await?;
.map_err(HttpFailure::OpenID)?; let (user_info, _) = conf.request_user_info(&token).await?;
let (user_info, _) = conf
.request_user_info(&token)
.await
.map_err(HttpFailure::OpenID)?;
if user_info.email_verified != Some(true) { if user_info.email_verified != Some(true) {
log::error!("Email is not verified!"); log::error!("Email is not verified!");
@@ -107,19 +103,23 @@ pub async fn finish_oidc(
} }
/// Get current user information /// Get current user information
pub async fn auth_info(auth: AuthExtractor) -> HttpResult { pub async fn auth_info(client: MatrixClientExtractor) -> HttpResult {
Ok(HttpResponse::Ok().json(ExtendedUserInfo::from_user(auth.user).await?)) Ok(HttpResponse::Ok().json(client.to_extended_user_info().await?))
} }
/// Sign out user /// Sign out user
pub async fn sign_out(auth: AuthExtractor, session: MatrixGWSession) -> HttpResult { pub async fn sign_out(
auth: AuthExtractor,
session: MatrixGWSession,
tx: web::Data<BroadcastSender>,
) -> HttpResult {
match auth.method { match auth.method {
AuthenticatedMethod::Cookie => { AuthenticatedMethod::Cookie => {
session.unset_current_user()?; session.unset_current_user()?;
} }
AuthenticatedMethod::Token(token) => { AuthenticatedMethod::Token(token) => {
token.delete(&auth.user.email).await?; token.delete(&auth.user.email, &tx).await?;
} }
AuthenticatedMethod::Dev => { AuthenticatedMethod::Dev => {

View File

@@ -0,0 +1,355 @@
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::controllers::server_controller::ServerConstraints;
use crate::extractors::matrix_client_extractor::MatrixClientExtractor;
use actix_multipart::form::MultipartForm;
use actix_web::dev::Payload;
use actix_web::{FromRequest, HttpRequest, HttpResponse, web};
use futures_util::{StreamExt, stream};
use matrix_sdk::Room;
use matrix_sdk::attachment::AttachmentConfig;
use matrix_sdk::deserialized_responses::{TimelineEvent, TimelineEventKind};
use matrix_sdk::media::MediaEventContent;
use matrix_sdk::room::MessagesOptions;
use matrix_sdk::room::edit::EditedContent;
use matrix_sdk::ruma::api::client::filter::RoomEventFilter;
use matrix_sdk::ruma::api::client::receipt::create_receipt::v3::ReceiptType;
use matrix_sdk::ruma::events::reaction::ReactionEventContent;
use matrix_sdk::ruma::events::receipt::ReceiptThread;
use matrix_sdk::ruma::events::relation::Annotation;
use matrix_sdk::ruma::events::room::message::{
MessageType, RoomMessageEvent, RoomMessageEventContent, RoomMessageEventContentWithoutRelation,
};
use matrix_sdk::ruma::events::{AnyMessageLikeEvent, AnyTimelineEvent};
use matrix_sdk::ruma::{MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedUserId, RoomId, UInt};
use serde::{Deserialize, Serialize};
use serde_json::value::RawValue;
use std::io::Read;
#[derive(Serialize)]
pub struct APIEvent {
pub id: OwnedEventId,
time: MilliSecondsSinceUnixEpoch,
sender: OwnedUserId,
data: Box<RawValue>,
}
impl APIEvent {
pub async fn from_evt(msg: TimelineEvent, room_id: &RoomId) -> anyhow::Result<Self> {
let (event, raw) = match &msg.kind {
TimelineEventKind::Decrypted(d) => (d.event.deserialize()?, d.event.json()),
TimelineEventKind::UnableToDecrypt { event, .. }
| TimelineEventKind::PlainText { event } => (
event.deserialize()?.into_full_event(room_id.to_owned()),
event.json(),
),
};
Ok(Self {
id: event.event_id().to_owned(),
time: event.origin_server_ts(),
sender: event.sender().to_owned(),
data: raw.to_owned(),
})
}
}
#[derive(Serialize)]
pub struct APIEventsList {
pub start: String,
pub end: Option<String>,
pub events: Vec<APIEvent>,
}
/// Get messages for a given room
pub(super) async fn get_events(
room: &Room,
limit: u32,
from: Option<&str>,
filter: Option<RoomEventFilter>,
) -> anyhow::Result<APIEventsList> {
let mut msg_opts = MessagesOptions::backward();
msg_opts.from = from.map(str::to_string);
msg_opts.limit = UInt::from(limit);
if let Some(filter) = filter {
msg_opts.filter = filter;
}
let messages = room.messages(msg_opts).await?;
Ok(APIEventsList {
start: messages.start,
end: messages.end,
events: stream::iter(messages.chunk)
.then(async |msg| APIEvent::from_evt(msg, room.room_id()).await)
.collect::<Vec<_>>()
.await
.into_iter()
.collect::<Result<Vec<_>, _>>()?,
})
}
#[derive(Deserialize)]
pub struct GetRoomEventsQuery {
#[serde(default)]
limit: Option<u32>,
#[serde(default)]
from: Option<String>,
}
/// Get the events for a room
pub async fn get_for_room(
client: MatrixClientExtractor,
path: web::Path<RoomIdInPath>,
query: web::Query<GetRoomEventsQuery>,
) -> HttpResult {
let Some(room) = client.client.client.get_room(&path.room_id) else {
return Ok(HttpResponse::NotFound().json("Room not found!"));
};
Ok(HttpResponse::Ok().json(
get_events(
&room,
query.limit.unwrap_or(500),
query.from.as_deref(),
None,
)
.await?,
))
}
#[derive(Deserialize)]
struct SendTextMessageRequest {
content: String,
}
pub async fn send_text_message(
client: MatrixClientExtractor,
path: web::Path<RoomIdInPath>,
) -> HttpResult {
let req = client.auth.decode_json_body::<SendTextMessageRequest>()?;
let Some(room) = client.client.client.get_room(&path.room_id) else {
return Ok(HttpResponse::NotFound().json("Room not found!"));
};
room.send(RoomMessageEventContent::text_plain(req.content))
.await?;
Ok(HttpResponse::Accepted().finish())
}
#[derive(Debug, actix_multipart::form::MultipartForm)]
pub struct SendFileForm {
#[multipart]
file: actix_multipart::form::tempfile::TempFile,
}
pub async fn send_file(
client: MatrixClientExtractor,
path: web::Path<RoomIdInPath>,
req: HttpRequest,
) -> HttpResult {
let Some(payload) = client.auth.payload else {
return Ok(HttpResponse::BadRequest().body("No payload included in request!"));
};
// Reconstruct multipart form from authenticated request
let mut form = MultipartForm::<SendFileForm>::from_request(
&req,
&mut Payload::from(bytes::Bytes::from(payload)),
)
.await?;
// Read attachment to end
let mut buff = Vec::with_capacity(form.file.size);
form.file.file.read_to_end(&mut buff)?;
if form.file.size > ServerConstraints::default().max_upload_file_size {
return Ok(HttpResponse::NotFound().json("Uploaded file is too large!"));
}
let Some(room) = client.client.client.get_room(&path.room_id) else {
return Ok(HttpResponse::NotFound().json("Room not found!"));
};
let Some(file_name) = form.file.file_name.as_deref() else {
return Ok(HttpResponse::BadRequest().json("File name must be specified!"));
};
let Some(mime_type) = form.file.content_type.as_ref() else {
return Ok(HttpResponse::BadRequest().json("File content type must be specified!"));
};
// Do send the file
room.send_attachment(file_name, mime_type, buff, AttachmentConfig::new())
.await?;
Ok(HttpResponse::Accepted().finish())
}
#[derive(serde::Deserialize)]
pub struct EventIdInPath {
pub(crate) event_id: OwnedEventId,
}
pub async fn set_text_content(
client: MatrixClientExtractor,
path: web::Path<RoomIdInPath>,
event_path: web::Path<EventIdInPath>,
) -> HttpResult {
let req = client.auth.decode_json_body::<SendTextMessageRequest>()?;
let Some(room) = client.client.client.get_room(&path.room_id) else {
return Ok(HttpResponse::NotFound().json("Room not found!"));
};
let edit_event = match room
.make_edit_event(
&event_path.event_id,
EditedContent::RoomMessage(RoomMessageEventContentWithoutRelation::text_plain(
req.content,
)),
)
.await
{
Ok(msg) => msg,
Err(e) => {
log::error!(
"Failed to created edit message event {}: {e}",
event_path.event_id
);
return Ok(HttpResponse::InternalServerError()
.json(format!("Failed to create edit message event! {e}")));
}
};
Ok(match room.send(edit_event).await {
Ok(_) => HttpResponse::Accepted().finish(),
Err(e) => {
log::error!("Failed to edit event message {}: {e}", event_path.event_id);
HttpResponse::InternalServerError().json(format!("Failed to edit event! {e}"))
}
})
}
pub async fn event_file(
req: HttpRequest,
client: MatrixClientExtractor,
path: web::Path<RoomIdInPath>,
event_path: web::Path<EventIdInPath>,
) -> HttpResult {
let query = web::Query::<MediaQuery>::from_request(&req, &mut Payload::None).await?;
let Some(room) = client.client.client.get_room(&path.room_id) else {
return Ok(HttpResponse::NotFound().json("Room not found!"));
};
let event = match room.load_or_fetch_event(&event_path.event_id, None).await {
Ok(event) => event,
Err(e) => {
log::error!("Failed to load event information! {e}");
return Ok(HttpResponse::InternalServerError()
.json(format!("Failed to load event information! {e}")));
}
};
let event = match event.kind {
TimelineEventKind::Decrypted(dec) => dec.event.deserialize()?,
TimelineEventKind::UnableToDecrypt { event, .. }
| TimelineEventKind::PlainText { event } => event
.deserialize()?
.into_full_event(room.room_id().to_owned()),
};
let AnyTimelineEvent::MessageLike(message) = event else {
return Ok(HttpResponse::BadRequest().json("Event is not message like!"));
};
let AnyMessageLikeEvent::RoomMessage(message) = message else {
return Ok(HttpResponse::BadRequest().json("Event is not a room message!"));
};
let RoomMessageEvent::Original(message) = message else {
return Ok(HttpResponse::BadRequest().json("Event has been redacted!"));
};
let (source, thumb_source) = match message.content.msgtype {
MessageType::Audio(c) => (c.source(), c.thumbnail_source()),
MessageType::File(c) => (c.source(), c.thumbnail_source()),
MessageType::Image(c) => (c.source(), c.thumbnail_source()),
MessageType::Location(c) => (c.source(), c.thumbnail_source()),
MessageType::Video(c) => (c.source(), c.thumbnail_source()),
_ => (None, None),
};
let source = match (query.thumbnail, source, thumb_source) {
(false, Some(s), _) => s,
(true, _, Some(s)) => s,
_ => return Ok(HttpResponse::NotFound().json("Requested file not available!")),
};
matrix_media_controller::serve_media(req, source, false).await
}
#[derive(Deserialize)]
struct EventReactionBody {
key: String,
}
pub async fn react_to_event(
client: MatrixClientExtractor,
path: web::Path<RoomIdInPath>,
event_path: web::Path<EventIdInPath>,
) -> HttpResult {
let body = client.auth.decode_json_body::<EventReactionBody>()?;
let Some(room) = client.client.client.get_room(&path.room_id) else {
return Ok(HttpResponse::NotFound().json("Room not found!"));
};
let annotation = Annotation::new(event_path.event_id.to_owned(), body.key.to_owned());
room.send(ReactionEventContent::from(annotation)).await?;
Ok(HttpResponse::Accepted().finish())
}
pub async fn redact_event(
client: MatrixClientExtractor,
path: web::Path<RoomIdInPath>,
event_path: web::Path<EventIdInPath>,
) -> HttpResult {
let Some(room) = client.client.client.get_room(&path.room_id) else {
return Ok(HttpResponse::NotFound().json("Room not found!"));
};
Ok(match room.redact(&event_path.event_id, None, None).await {
Ok(_) => HttpResponse::Accepted().finish(),
Err(e) => {
log::error!("Failed to redact event {}: {e}", event_path.event_id);
HttpResponse::InternalServerError().json(format!("Failed to redact event! {e}"))
}
})
}
/// Send receipt for event
pub async fn receipt(
client: MatrixClientExtractor,
path: web::Path<RoomIdInPath>,
event_path: web::Path<EventIdInPath>,
) -> HttpResult {
let Some(room) = client.client.client.get_room(&path.room_id) else {
return Ok(HttpResponse::NotFound().json("Room not found"));
};
room.send_single_receipt(
ReceiptType::Read,
ReceiptThread::Main,
event_path.event_id.clone(),
)
.await?;
Ok(HttpResponse::Accepted().finish())
}

View File

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

View File

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

View File

@@ -0,0 +1,180 @@
use crate::controllers::HttpResult;
use crate::controllers::matrix::matrix_event_controller::{APIEvent, get_events};
use crate::controllers::matrix::matrix_media_controller;
use crate::extractors::matrix_client_extractor::MatrixClientExtractor;
use actix_web::{HttpRequest, HttpResponse, web};
use futures_util::{StreamExt, stream};
use matrix_sdk::notification_settings::{
IsEncrypted, IsOneToOne, NotificationSettings, RoomNotificationMode,
};
use matrix_sdk::room::ParentSpace;
use matrix_sdk::ruma::events::receipt::{ReceiptThread, ReceiptType};
use matrix_sdk::ruma::{
MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedMxcUri, OwnedRoomId, OwnedUserId,
};
use matrix_sdk::{Room, RoomMemberships};
#[derive(serde::Serialize)]
pub struct APIRoomInfo {
id: OwnedRoomId,
name: Option<String>,
members: Vec<OwnedUserId>,
avatar: Option<OwnedMxcUri>,
is_space: bool,
parents: Vec<OwnedRoomId>,
number_unread_messages: u64,
notifications: RoomNotificationMode,
latest_event: Option<APIEvent>,
}
impl APIRoomInfo {
async fn from_room(r: &Room, notif: &NotificationSettings) -> anyhow::Result<Self> {
// Get parent spaces
let parent_spaces = r
.parent_spaces()
.await?
.collect::<Vec<_>>()
.await
.into_iter()
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.filter_map(|d| match d {
ParentSpace::Reciprocal(r) | ParentSpace::WithPowerlevel(r) => {
Some(r.room_id().to_owned())
}
_ => None,
})
.collect::<Vec<_>>();
let members = r
.members(RoomMemberships::ACTIVE)
.await?
.into_iter()
.map(|r| r.user_id().to_owned())
.collect::<Vec<_>>();
let notifications = notif
.get_user_defined_room_notification_mode(r.room_id())
.await
.unwrap_or(
notif
.get_default_room_notification_mode(
IsEncrypted::from(r.encryption_state().is_encrypted()),
IsOneToOne::from(members.len() == 2),
)
.await,
);
Ok(Self {
id: r.room_id().to_owned(),
name: r.name(),
members,
avatar: r.avatar_url(),
is_space: r.is_space(),
parents: parent_spaces,
number_unread_messages: r.unread_notification_counts().notification_count,
notifications,
latest_event: get_events(r, 1, None, None)
.await?
.events
.into_iter()
.next(),
})
}
}
/// Get the list of joined rooms of the user
pub async fn joined_rooms(client: MatrixClientExtractor) -> HttpResult {
let notifs = client.client.client.notification_settings().await;
let list = stream::iter(client.client.client.joined_rooms())
.then(async |room| APIRoomInfo::from_room(&room, &notifs).await)
.collect::<Vec<_>>()
.await
.into_iter()
.collect::<Result<Vec<_>, _>>()?;
Ok(HttpResponse::Ok().json(list))
}
/// Get joined spaces rooms of user
pub async fn get_joined_spaces(client: MatrixClientExtractor) -> HttpResult {
let notifs = client.client.client.notification_settings().await;
let list = stream::iter(client.client.client.joined_space_rooms())
.then(async |room| APIRoomInfo::from_room(&room, &notifs).await)
.collect::<Vec<_>>()
.await
.into_iter()
.collect::<Result<Vec<_>, _>>()?;
Ok(HttpResponse::Ok().json(list))
}
#[derive(serde::Deserialize)]
pub struct RoomIdInPath {
pub(crate) room_id: OwnedRoomId,
}
/// Get the list of joined rooms of the user
pub async fn single_room_info(
client: MatrixClientExtractor,
path: web::Path<RoomIdInPath>,
) -> HttpResult {
let notifs = client.client.client.notification_settings().await;
Ok(match client.client.client.get_room(&path.room_id) {
None => HttpResponse::NotFound().json("Room not found"),
Some(r) => HttpResponse::Ok().json(APIRoomInfo::from_room(&r, &notifs).await?),
})
}
/// Get room avatar
pub async fn room_avatar(
req: HttpRequest,
client: MatrixClientExtractor,
path: web::Path<RoomIdInPath>,
) -> HttpResult {
let Some(room) = client.client.client.get_room(&path.room_id) else {
return Ok(HttpResponse::NotFound().json("Room not found"));
};
let Some(uri) = room.avatar_url() else {
return Ok(HttpResponse::NotFound().json("Room has no avatar"));
};
matrix_media_controller::serve_mxc_file(req, uri).await
}
#[derive(serde::Serialize)]
pub struct UserReceipt {
user: OwnedUserId,
event_id: OwnedEventId,
ts: Option<MilliSecondsSinceUnixEpoch>,
}
/// Get room receipts
pub async fn receipts(client: MatrixClientExtractor, path: web::Path<RoomIdInPath>) -> HttpResult {
let Some(room) = client.client.client.get_room(&path.room_id) else {
return Ok(HttpResponse::NotFound().json("Room not found"));
};
let members = room.members(RoomMemberships::ACTIVE).await?;
let mut receipts = Vec::new();
for m in members {
let Some((event_id, receipt)) = room
.load_user_receipt(ReceiptType::Read, ReceiptThread::Main, m.user_id())
.await?
else {
continue;
};
receipts.push(UserReceipt {
user: m.user_id().to_owned(),
event_id,
ts: receipt.ts,
})
}
Ok(HttpResponse::Ok().json(receipts))
}

View File

@@ -0,0 +1,34 @@
use crate::controllers::HttpResult;
use crate::extractors::matrix_client_extractor::MatrixClientExtractor;
use actix_web::HttpResponse;
use matrix_sdk_ui::spaces::SpaceService;
use matrix_sdk_ui::spaces::room_list::SpaceRoomListPaginationState;
use std::collections::HashMap;
/// Get space hierarchy
pub async fn hierarchy(client: MatrixClientExtractor) -> HttpResult {
let spaces = client.client.client.joined_space_rooms();
let space_service = SpaceService::new(client.client.client);
let mut hierarchy = HashMap::new();
for space in spaces {
let rooms = space_service
.space_room_list(space.room_id().to_owned())
.await;
while !matches!(
rooms.pagination_state(),
SpaceRoomListPaginationState::Idle { end_reached: true }
) {
rooms.paginate().await?;
}
hierarchy.insert(
space.room_id().to_owned(),
rooms
.rooms()
.into_iter()
.map(|room| room.room_id)
.collect::<Vec<_>>(),
);
}
Ok(HttpResponse::Ok().json(hierarchy))
}

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,15 @@
use actix_web::http::StatusCode; use actix_web::http::StatusCode;
use actix_web::{HttpResponse, ResponseError}; use actix_web::{HttpResponse, ResponseError};
use std::error::Error; use light_openid::errors::OpenIdError;
pub mod auth_controller; pub mod auth_controller;
pub mod matrix;
pub mod matrix_link_controller; pub mod matrix_link_controller;
pub mod matrix_sync_thread_controller;
pub mod server_controller; pub mod server_controller;
pub mod static_controller;
pub mod tokens_controller;
pub mod ws_controller;
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
pub enum HttpFailure { pub enum HttpFailure {
@@ -13,9 +18,19 @@ pub enum HttpFailure {
#[error("this resource was not found")] #[error("this resource was not found")]
NotFound, NotFound,
#[error("an unspecified open id error occurred: {0}")] #[error("an unspecified open id error occurred: {0}")]
OpenID(Box<dyn Error>), OpenID(#[from] OpenIdError),
#[error("an unspecified internal error occurred: {0}")] #[error("an unspecified internal error occurred: {0}")]
InternalError(#[from] anyhow::Error), InternalError(#[from] anyhow::Error),
#[error("Actix web error: {0}")]
ActixError(#[from] actix_web::Error),
#[error("Matrix error: {0}")]
MatrixError(#[from] matrix_sdk::Error),
#[error("Matrix decryptor error: {0}")]
MatrixDecryptorError(#[from] matrix_sdk::encryption::DecryptorError),
#[error("Serde JSON error: {0}")]
SerdeJSON(#[from] serde_json::Error),
#[error("Standard library error: {0}")]
StdLibError(#[from] std::io::Error),
} }
impl ResponseError for HttpFailure { impl ResponseError for HttpFailure {
@@ -28,7 +43,9 @@ impl ResponseError for HttpFailure {
} }
fn error_response(&self) -> HttpResponse { fn error_response(&self) -> HttpResponse {
HttpResponse::build(self.status_code()).body(self.to_string()) HttpResponse::build(self.status_code())
.content_type("text/plain")
.body(self.to_string())
} }
} }

View File

@@ -39,6 +39,7 @@ pub struct ServerConstraints {
pub token_name: LenConstraints, pub token_name: LenConstraints,
pub token_ip_net: LenConstraints, pub token_ip_net: LenConstraints,
pub token_max_inactivity: LenConstraints, pub token_max_inactivity: LenConstraints,
pub max_upload_file_size: usize,
} }
impl Default for ServerConstraints { impl Default for ServerConstraints {
@@ -47,6 +48,7 @@ impl Default for ServerConstraints {
token_name: LenConstraints::new(5, 255), token_name: LenConstraints::new(5, 255),
token_ip_net: LenConstraints::max_only(44), token_ip_net: LenConstraints::max_only(44),
token_max_inactivity: LenConstraints::new(3600, 3600 * 24 * 365), token_max_inactivity: LenConstraints::new(3600, 3600 * 24 * 365),
max_upload_file_size: 20_000_000,
} }
} }
} }

View File

@@ -0,0 +1,45 @@
#[cfg(debug_assertions)]
pub use serve_static_debug::{root_index, serve_static_content};
#[cfg(not(debug_assertions))]
pub use serve_static_release::{root_index, serve_static_content};
#[cfg(debug_assertions)]
mod serve_static_debug {
use actix_web::{HttpResponse, Responder};
pub async fn root_index() -> impl Responder {
HttpResponse::Ok().body("Hello world! Debug=on for Matrix Gateway!")
}
pub async fn serve_static_content() -> impl Responder {
HttpResponse::NotFound().body("Hello world! Static assets are not served in debug mode")
}
}
#[cfg(not(debug_assertions))]
mod serve_static_release {
use actix_web::{HttpResponse, Responder, web};
use rust_embed::RustEmbed;
#[derive(RustEmbed)]
#[folder = "static/"]
struct Asset;
fn handle_embedded_file(path: &str, can_fallback: bool) -> HttpResponse {
match (Asset::get(path), can_fallback) {
(Some(content), _) => HttpResponse::Ok()
.content_type(mime_guess::from_path(path).first_or_octet_stream().as_ref())
.body(content.data.into_owned()),
(None, false) => HttpResponse::NotFound().body("404 Not Found"),
(None, true) => handle_embedded_file("index.html", false),
}
}
pub async fn root_index() -> impl Responder {
handle_embedded_file("index.html", false)
}
pub async fn serve_static_content(path: web::Path<String>) -> impl Responder {
handle_embedded_file(path.as_ref(), !path.as_ref().starts_with("static/"))
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -12,30 +12,38 @@
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@fontsource/roboto": "^5.2.8", "@fontsource/roboto": "^5.2.10",
"@mdi/js": "^7.4.47", "@mui/icons-material": "^7.3.8",
"@mdi/react": "^1.6.1", "@mui/material": "^7.3.8",
"@mui/icons-material": "^7.3.5", "@mui/x-data-grid": "^8.27.3",
"@mui/material": "^7.3.5", "@mui/x-date-pickers": "^8.27.2",
"react": "^19.1.1", "date-and-time": "^4.3.0",
"react-dom": "^19.1.1", "dayjs": "^1.11.19",
"react-router": "^7.9.5" "emoji-picker-react": "^4.18.0",
"filesize": "^11.0.13",
"is-cidr": "^6.0.3",
"qrcode.react": "^4.2.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-favicon": "^2.0.7",
"react-json-view-lite": "^2.5.0",
"react-router": "^7.13.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.36.0", "@eslint/js": "^10.0.1",
"@types/node": "^24.6.0", "@types/node": "^25.3.3",
"@types/react": "^19.1.16", "@types/react": "^19.2.14",
"@types/react-dom": "^19.1.9", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.0.4", "@vitejs/plugin-react": "^5.1.4",
"eslint": "^9.36.0", "eslint": "^9.39.3",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.22", "eslint-plugin-react-refresh": "^0.4.26",
"globals": "^16.4.0", "globals": "^17.4.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.45.0", "typescript-eslint": "^8.56.1",
"vite": "npm:rolldown-vite@7.1.14" "vite": "npm:rolldown-vite@7.3.1"
}, },
"overrides": { "overrides": {
"vite": "npm:rolldown-vite@7.1.14" "vite": "npm:rolldown-vite@7.3.1"
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

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

View File

@@ -4,21 +4,21 @@ interface RequestParams {
uri: string; uri: string;
method: "GET" | "POST" | "DELETE" | "PATCH" | "PUT"; method: "GET" | "POST" | "DELETE" | "PATCH" | "PUT";
allowFail?: boolean; allowFail?: boolean;
jsonData?: any; jsonData?: unknown;
formData?: FormData; formData?: FormData;
upProgress?: (progress: number) => void; upProgress?: (progress: number) => void;
downProgress?: (e: { progress: number; total: number }) => void; downProgress?: (e: { progress: number; total: number }) => void;
} }
interface APIResponse { interface APIResponse {
data: any; data: unknown;
status: number; status: number;
} }
export class ApiError extends Error { export class ApiError extends Error {
public code: number; public code: number;
public data: number; public data: unknown;
constructor(message: string, code: number, data: any) { constructor(message: string, code: number, data: unknown) {
super(`HTTP status: ${code}\nMessage: ${message}\nData=${data}`); super(`HTTP status: ${code}\nMessage: ${message}\nData=${data}`);
this.code = code; this.code = code;
this.data = data; this.data = data;
@@ -57,6 +57,7 @@ export class APIClient {
*/ */
static async exec(args: RequestParams): Promise<APIResponse> { static async exec(args: RequestParams): Promise<APIResponse> {
let body: string | undefined | FormData = undefined; let body: string | undefined | FormData = undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const headers: any = {}; const headers: any = {};
// JSON request // JSON request

View File

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

View File

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

View File

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

View File

@@ -16,6 +16,7 @@ export interface ServerConstraints {
token_name: LenConstraint; token_name: LenConstraint;
token_ip_net: LenConstraint; token_ip_net: LenConstraint;
token_max_inactivity: LenConstraint; token_max_inactivity: LenConstraint;
max_upload_file_size: number;
} }
export interface LenConstraint { export interface LenConstraint {
@@ -35,7 +36,7 @@ export class ServerApi {
uri: "/server/config", uri: "/server/config",
method: "GET", method: "GET",
}) })
).data; ).data as ServerConfig;
} }
/** /**

View File

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

View File

@@ -0,0 +1,85 @@
import { APIClient } from "./ApiClient";
import type { MessageType } from "./matrix/MatrixApiEvent";
interface BaseRoomEvent {
time: number;
room_id: string;
event_id: string;
sender: string;
origin_server_ts: number;
}
export interface RoomMessageEvent extends BaseRoomEvent {
type: "RoomMessageEvent";
data: {
msgtype: MessageType;
body: string;
"m.relates_to"?: {
rel_type?: "m.replace" | string;
event_id?: string;
"m.in_reply_to"?:{
event_id?:string
}
};
"m.new_content"?: {
msgtype?: MessageType;
body?: string;
};
url?: string;
file?: { url: string };
};
}
export interface RoomReactionEvent extends BaseRoomEvent {
type: "RoomReactionEvent";
data: {
"m.relates_to": {
rel_type: string;
event_id: string;
key: string;
};
};
}
export interface RoomRedactionEvent extends BaseRoomEvent {
type: "RoomRedactionEvent";
data: {
redacts: string;
};
}
export interface ReceiptEventEntry {
event: string;
user: string;
ts?: number;
}
export interface RoomReceiptEvent {
time: number;
type: "ReceiptEvent";
room_id: string;
receipts: ReceiptEventEntry[];
}
export interface RoomTypingEvent {
time: number;
type: "TypingEvent";
room_id: string;
user_ids: string[];
}
export type WsMessage =
| RoomMessageEvent
| RoomReactionEvent
| RoomRedactionEvent
| RoomReceiptEvent
| RoomTypingEvent;
export class WsApi {
/**
* Get WebSocket URL
*/
static get WsURL(): string {
return APIClient.backendURL() + "/ws";
}
}

View File

@@ -0,0 +1,169 @@
import { APIClient } from "../ApiClient";
import type { Room } from "./MatrixApiRoom";
export type MessageType =
| "m.text"
| "m.image"
| "m.audio"
| "m.file"
| "m.video"
| "_OTHER_";
export interface MatrixRoomMessage {
type: "m.room.message";
content: {
body: string;
msgtype: MessageType;
"m.relates_to"?: {
event_id?: string;
rel_type?: "m.replace" | string;
"m.in_reply_to"?: {
event_id?: string;
};
};
url?: string;
file?: {
url: string;
};
};
}
export interface MatrixReaction {
type: "m.reaction";
content: {
"m.relates_to": {
event_id: string;
key: string;
};
};
}
export interface MatrixRoomRedaction {
type: "m.room.redaction";
redacts: string;
}
export type MatrixEventData =
| MatrixRoomMessage
| MatrixReaction
| MatrixRoomRedaction
| { type: "other" };
export interface MatrixEvent {
id: string;
time: number;
sender: string;
data: MatrixEventData;
}
export interface MatrixEventsList {
start: string;
end?: string;
events: MatrixEvent[];
}
export class MatrixApiEvent {
/**
* Get Matrix room events
*/
static async GetRoomEvents(
room: Room,
from?: string,
): Promise<MatrixEventsList> {
return (
await APIClient.exec({
method: "GET",
uri:
`/matrix/room/${encodeURIComponent(room.id)}/events?limit=200` +
(from ? `&from=${from}` : ""),
})
).data as MatrixEventsList;
}
/**
* Get Matrix event file URL
*/
static GetEventFileURL(
room: Room,
event_id: string,
thumbnail: boolean,
): string {
return `${APIClient.ActualBackendURL()}/matrix/room/${
room.id
}/event/${event_id}/file?thumbnail=${thumbnail}`;
}
/**
* Send text message
*/
static async SendTextMessage(room: Room, content: string): Promise<void> {
await APIClient.exec({
method: "POST",
uri: `/matrix/room/${room.id}/send_text_message`,
jsonData: { content },
});
}
/**
* Send file message
*/
static async SendFileMessage(room: Room, file: Blob): Promise<void> {
const formData = new FormData();
formData.set("file", file);
await APIClient.exec({
method: "POST",
uri: `/matrix/room/${room.id}/send_file`,
formData,
});
}
/**
* Edit text message content
*/
static async SetTextMessageContent(
room: Room,
event_id: string,
content: string,
): Promise<void> {
await APIClient.exec({
method: "POST",
uri: `/matrix/room/${room.id}/event/${event_id}/set_text_content`,
jsonData: { content },
});
}
/**
* React to event
*/
static async ReactToEvent(
room: Room,
event_id: string,
key: string,
): Promise<void> {
await APIClient.exec({
method: "POST",
uri: `/matrix/room/${room.id}/event/${event_id}/react`,
jsonData: { key },
});
}
/**
* Delete an event
*/
static async DeleteEvent(room: Room, event_id: string): Promise<void> {
await APIClient.exec({
method: "DELETE",
uri: `/matrix/room/${room.id}/event/${event_id}`,
});
}
/**
* Send event receipt
*/
static async SendReceipt(room: Room, event_id: string): Promise<void> {
await APIClient.exec({
method: "POST",
uri: `/matrix/room/${room.id}/event/${event_id}/receipt`,
});
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,40 @@
import { APIClient } from "../ApiClient";
export type SpaceHierarchy = Map<string, string[]>;
export class MatrixApiSpace {
/**
* Request Matrix space hierarchy
*/
static async Hierarchy(): Promise<SpaceHierarchy> {
const hierarchy = new Map(
Object.entries(
(
await APIClient.exec({
method: "GET",
uri: "/matrix/space/hierarchy",
})
).data as { [s: string]: string[] }
)
) as SpaceHierarchy;
// Simplify hierarchy
while (true) {
let changed = false;
for (const [roomid, children] of hierarchy) {
for (const child of children) {
if (!hierarchy.has(child)) continue;
hierarchy.set(roomid, [
...hierarchy.get(roomid)!,
...hierarchy.get(child)!,
]);
hierarchy.delete(child);
changed = true;
}
}
if (!changed) break;
}
return hierarchy;
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,18 @@
import { Icon } from "@mui/material";
import { useActualColorMode } from "../widgets/dashboard/ThemeSwitcher";
export function AppIcon(p: { src: string; size?: string }): React.ReactElement {
const { mode } = useActualColorMode();
return (
<Icon style={{ display: "inline-flex", width: p.size, height: p.size }}>
<img
style={{
height: "100%",
flex: 1,
backgroundColor: mode === "dark" ? "white" : "black",
mask: `url("${p.src}")`,
}}
/>
</Icon>
);
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M9 5C7.9 5 7 5.9 7 7V21L11 17H20C21.1 17 22 16.1 22 15V7C22 5.9 21.1 5 20 5H9M3 7C2.4 7 2 7.4 2 8S2.4 9 3 9H5V7H3M11 8H19V10H11V8M2 11C1.4 11 1 11.4 1 12S1.4 13 2 13H5V11H2M11 12H16V14H11V12M1 15C.4 15 0 15.4 0 16C0 16.6 .4 17 1 17H5V15H1Z" /></svg>

After

Width:  |  Height:  |  Size: 318 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M14,2L11,3.5V19.94C7,19.5 4,17.46 4,15C4,12.75 6.5,10.85 10,10.22V8.19C4.86,8.88 1,11.66 1,15C1,18.56 5.36,21.5 11,21.94C11.03,21.94 11.06,21.94 11.09,21.94L14,20.5V2M15,8.19V10.22C16.15,10.43 17.18,10.77 18.06,11.22L16.5,12L23,13.5L22.5,9L20.5,10C19,9.12 17.12,8.47 15,8.19Z" /></svg>

After

Width:  |  Height:  |  Size: 354 B

View File

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

View File

@@ -3,21 +3,24 @@ import "@fontsource/roboto/400.css";
import "@fontsource/roboto/500.css"; import "@fontsource/roboto/500.css";
import "@fontsource/roboto/700.css"; import "@fontsource/roboto/700.css";
import { CssBaseline } from "@mui/material";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import "./index.css"; import { ServerApi } from "./api/ServerApi";
import { App } from "./App"; import { App } from "./App";
import { AlertDialogProvider } from "./hooks/contexts_provider/AlertDialogProvider"; import { AlertDialogProvider } from "./hooks/contexts_provider/AlertDialogProvider";
import { ConfirmDialogProvider } from "./hooks/contexts_provider/ConfirmDialogProvider"; import { ConfirmDialogProvider } from "./hooks/contexts_provider/ConfirmDialogProvider";
import { SnackbarProvider } from "./hooks/contexts_provider/SnackbarProvider";
import { LoadingMessageProvider } from "./hooks/contexts_provider/LoadingMessageProvider"; import { LoadingMessageProvider } from "./hooks/contexts_provider/LoadingMessageProvider";
import { AsyncWidget } from "./widgets/AsyncWidget"; import { SnackbarProvider } from "./hooks/contexts_provider/SnackbarProvider";
import { ServerApi } from "./api/ServerApi"; import "./index.css";
import { AppTheme } from "./theme/AppTheme"; import { AppTheme } from "./theme/AppTheme";
import { CssBaseline } from "@mui/material"; import { AsyncWidget } from "./widgets/AsyncWidget";
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="en">
<AppTheme> <AppTheme>
<CssBaseline enableColorScheme /> <CssBaseline enableColorScheme />
<AlertDialogProvider> <AlertDialogProvider>
@@ -37,5 +40,6 @@ createRoot(document.getElementById("root")!).render(
</ConfirmDialogProvider> </ConfirmDialogProvider>
</AlertDialogProvider> </AlertDialogProvider>
</AppTheme> </AppTheme>
</LocalizationProvider>
</StrictMode> </StrictMode>
); );

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,56 @@
import React from "react";
import { JsonView, darkStyles } from "react-json-view-lite";
import "react-json-view-lite/dist/index.css";
import { type WsMessage } from "../api/WsApi";
import { useUserInfo } from "../widgets/dashboard/BaseAuthenticatedPage";
import { MatrixGWRouteContainer } from "../widgets/MatrixGWRouteContainer";
import { MatrixWS, WSState } from "../widgets/messages/MatrixWS";
import { NotLinkedAccountMessage } from "../widgets/NotLinkedAccountMessage";
type TimestampedMessages = WsMessage & { time: number };
export function WSDebugRoute(): React.ReactElement {
const user = useUserInfo();
const [state, setState] = React.useState<string>(WSState.Closed);
const [messages, setMessages] = React.useState<TimestampedMessages[]>([]);
const handleMessage = (msg: WsMessage) => {
setMessages((l) => [...l, msg]);
};
if (!user.info.matrix_account_connected) return <NotLinkedAccountMessage />;
return (
<MatrixGWRouteContainer label={"WebSocket Debug"}>
{/* Status bar */}
<div style={{ display: "flex", alignItems: "center" }}>
<span style={{ marginRight: "0.5em" }}>State: </span>
<span
style={{
marginRight: "0.5em",
color: state == WSState.Connected ? "green" : "red",
}}
>
{state}
</span>
<MatrixWS onStateChange={setState} onMessage={handleMessage} />
</div>
{/* WS messages list */}
{messages.map((msg, id) => (
<div style={{ margin: "10px", backgroundColor: "black" }}>
<JsonView
key={id}
data={msg}
shouldExpandNode={(level) => level < 2}
style={{
...darkStyles,
container: "",
}}
/>
</div>
))}
</MatrixGWRouteContainer>
);
}

View File

@@ -1,9 +1,9 @@
import { Alert, Box, Button, CircularProgress } from "@mui/material"; import { Alert, Box, Button, CircularProgress } from "@mui/material";
import Icon from "@mdi/react";
import { mdiOpenid } from "@mdi/js";
import { ServerApi } from "../../api/ServerApi";
import React from "react"; import React from "react";
import { AuthApi } from "../../api/AuthApi"; import { AuthApi } from "../../api/AuthApi";
import { ServerApi } from "../../api/ServerApi";
import { AppIcon } from "../../icons/AppIcon";
import openid from "../../icons/openid.svg";
export function LoginRoute(): React.ReactElement { export function LoginRoute(): React.ReactElement {
const [loading, setLoading] = React.useState(false); const [loading, setLoading] = React.useState(false);
@@ -40,7 +40,7 @@ export function LoginRoute(): React.ReactElement {
fullWidth fullWidth
variant="outlined" variant="outlined"
onClick={authWithOpenID} onClick={authWithOpenID}
startIcon={<Icon path={mdiOpenid} size={1} />} startIcon={<AppIcon src={openid} />}
> >
Sign in with {ServerApi.Config.oidc_provider_name} Sign in with {ServerApi.Config.oidc_provider_name}
</Button> </Button>

View File

@@ -6,7 +6,6 @@ import { svgIconClasses } from "@mui/material/SvgIcon";
import { typographyClasses } from "@mui/material/Typography"; import { typographyClasses } from "@mui/material/Typography";
import { gray, green, red } from "../themePrimitives"; import { gray, green, red } from "../themePrimitives";
/* eslint-disable import/prefer-default-export */
export const dataDisplayCustomizations: Components<Theme> = { export const dataDisplayCustomizations: Components<Theme> = {
MuiList: { MuiList: {
styleOverrides: { styleOverrides: {

View File

@@ -1,7 +1,6 @@
import { type Theme, alpha, type Components } from "@mui/material/styles"; import { type Theme, alpha, type Components } from "@mui/material/styles";
import { gray, orange } from "../themePrimitives"; import { gray, orange } from "../themePrimitives";
/* eslint-disable import/prefer-default-export */
export const feedbackCustomizations: Components<Theme> = { export const feedbackCustomizations: Components<Theme> = {
MuiAlert: { MuiAlert: {
styleOverrides: { styleOverrides: {

View File

@@ -8,7 +8,6 @@ import CheckRoundedIcon from "@mui/icons-material/CheckRounded";
import RemoveRoundedIcon from "@mui/icons-material/RemoveRounded"; import RemoveRoundedIcon from "@mui/icons-material/RemoveRounded";
import { gray, brand } from "../themePrimitives"; import { gray, brand } from "../themePrimitives";
/* eslint-disable import/prefer-default-export */
export const inputsCustomizations: Components<Theme> = { export const inputsCustomizations: Components<Theme> = {
MuiButtonBase: { MuiButtonBase: {
defaultProps: { defaultProps: {

View File

@@ -9,7 +9,6 @@ import { tabClasses } from "@mui/material/Tab";
import UnfoldMoreRoundedIcon from "@mui/icons-material/UnfoldMoreRounded"; import UnfoldMoreRoundedIcon from "@mui/icons-material/UnfoldMoreRounded";
import { gray, brand } from "../themePrimitives"; import { gray, brand } from "../themePrimitives";
/* eslint-disable import/prefer-default-export */
export const navigationCustomizations: Components<Theme> = { export const navigationCustomizations: Components<Theme> = {
MuiMenuItem: { MuiMenuItem: {
styleOverrides: { styleOverrides: {

View File

@@ -1,7 +1,6 @@
import { alpha, type Theme, type Components } from "@mui/material/styles"; import { alpha, type Theme, type Components } from "@mui/material/styles";
import { gray } from "../themePrimitives"; import { gray } from "../themePrimitives";
/* eslint-disable import/prefer-default-export */
export const surfacesCustomizations: Components<Theme> = { export const surfacesCustomizations: Components<Theme> = {
MuiAccordion: { MuiAccordion: {
defaultProps: { defaultProps: {

View File

@@ -24,8 +24,6 @@ declare module "@mui/material/styles" {
900: string; 900: string;
} }
interface PaletteColor extends ColorRange {}
interface Palette { interface Palette {
baseShadow: string; baseShadow: string;
} }
@@ -405,10 +403,10 @@ export const shape = {
borderRadius: 8, borderRadius: 8,
}; };
// @ts-ignore
const defaultShadows: Shadows = [ const defaultShadows: Shadows = [
"none", "none",
"var(--template-palette-baseShadow)", "var(--template-palette-baseShadow)",
...defaultTheme.shadows.slice(2), ...defaultTheme.shadows.slice(2),
]; ] as never;
export const shadows = defaultShadows; export const shadows = defaultShadows;

View File

@@ -0,0 +1,78 @@
import { format } from "date-and-time";
/**
* Get UNIX time
*
* @returns Number of seconds since Epoch
*/
export function time(): number {
return Math.floor(new Date().getTime() / 1000);
}
/**
* Get UNIX time
*
* @returns Number of milliseconds since Epoch
*/
export function timeMs(): number {
return new Date().getTime();
}
export function formatDateTime(time: number): string {
const t = new Date();
t.setTime(1000 * time);
return format(t, "DD/MM/YYYY HH:mm:ss");
}
export function formatDate(time: number): string {
const t = new Date();
t.setTime(1000 * time);
return format(t, "DD/MM/YYYY");
}
export function timeDiff(a: number, b: number): string {
let diff = b - a;
if (diff === 0) return "now";
if (diff === 1) return "1 second";
if (diff < 60) {
return `${diff} seconds`;
}
diff = Math.floor(diff / 60);
if (diff === 1) return "1 minute";
if (diff < 60) {
return `${diff} minutes`;
}
diff = Math.floor(diff / 60);
if (diff === 1) return "1 hour";
if (diff < 24) {
return `${diff} hours`;
}
const diffDays = Math.floor(diff / 24);
if (diffDays === 1) return "1 day";
if (diffDays < 31) {
return `${diffDays} days`;
}
diff = Math.floor(diffDays / 31);
if (diff < 12) {
return `${diff} month`;
}
const diffYears = Math.floor(diffDays / 365);
if (diffYears === 1) return "1 year";
return `${diffYears} years`;
}
export function timeDiffFromNow(t: number): string {
return timeDiff(t, time());
}

View File

@@ -0,0 +1,33 @@
import { filesize } from "filesize";
/**
* Select a file to upload
*/
export async function selectFileToUpload(p: {
allowedTypes?: string[];
maxSize?: number;
}): Promise<Blob | null> {
// Create file element
const fileEl = document.createElement("input");
fileEl.type = "file";
if (p.allowedTypes && p.allowedTypes.length > 0)
fileEl.accept = p.allowedTypes.join(",");
fileEl.click();
// Wait for a file to be chosen
await new Promise((res) =>
fileEl.addEventListener("change", () => res(null)),
);
if ((fileEl.files?.length ?? 0) === 0) return null;
const file = fileEl.files![0];
// Check file size
if (p.maxSize && file.size > p.maxSize) {
throw new Error(
`The file is too big ! (max accepted file size : ${filesize(p.maxSize)})`,
);
}
return file;
}

View File

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

View File

@@ -0,0 +1,241 @@
import dayjs from "dayjs";
import type {
MatrixEvent,
MatrixEventData,
MatrixEventsList,
MessageType,
} from "../api/matrix/MatrixApiEvent";
import type { Receipt, Room } from "../api/matrix/MatrixApiRoom";
import type { WsMessage } from "../api/WsApi";
import { timeMs } from "./DateUtils";
export interface MessageReaction {
event_id: string;
account: string;
key: string;
}
export interface Message {
event_id: string;
account: string;
time_sent: number;
time_sent_dayjs: dayjs.Dayjs;
modified: boolean;
inReplyTo?: string;
reactions: Map<string, MessageReaction[]>;
content: string;
type: MessageType;
file?: string;
}
export class RoomEventsManager {
readonly room: Room;
private events: MatrixEvent[];
private receipts: Receipt[];
messages: Message[];
endToken?: string;
typingUsers: string[];
receiptsEventsMap: Map<string, Receipt[]>;
get canLoadOlder(): boolean {
return !!this.endToken && this.events.length > 0;
}
constructor(
room: Room,
initialMessages: MatrixEventsList,
receipts: Receipt[]
) {
this.room = room;
this.events = [];
this.receipts = receipts;
this.messages = [];
this.typingUsers = [];
this.receiptsEventsMap = new Map();
this.processNewEvents(initialMessages);
}
/**
* Process events given by the API
*/
processNewEvents(evts: MatrixEventsList) {
this.endToken = evts.end;
this.events = [...this.events, ...evts.events];
this.rebuildMessagesList();
}
processWsMessage(m: WsMessage) {
if (m.room_id !== this.room.id) {
console.debug("Not an event for current room.");
return false;
}
let data: MatrixEventData;
if (m.type === "RoomReactionEvent") {
data = {
type: "m.reaction",
content: {
"m.relates_to": {
key: m.data["m.relates_to"].key,
event_id: m.data["m.relates_to"].event_id,
},
},
};
} else if (m.type === "RoomRedactionEvent") {
data = {
type: "m.room.redaction",
redacts: m.data.redacts,
};
} else if (m.type === "RoomMessageEvent") {
data = {
type: "m.room.message",
content: {
body: m.data["m.new_content"]?.body ?? m.data.body,
msgtype: m.data.msgtype,
"m.relates_to": m.data["m.relates_to"],
url: m.data.url,
file: m.data.file,
},
};
} else if (m.type === "ReceiptEvent") {
for (const r of m.receipts) {
const prevReceipt = this.receipts.find(
(needle) => r.user === needle.user
);
// Create new receipt
if (!prevReceipt)
this.receipts.push({
user: r.user,
event_id: r.event,
ts: r.ts ?? timeMs(),
});
// Update receipt
else {
prevReceipt.event_id = r.event;
prevReceipt.ts = r.ts ?? timeMs();
}
}
this.rebuildMessagesList();
return true; // Emphemeral event
} else if (m.type === "TypingEvent") {
this.typingUsers = m.user_ids;
return true; // Not a real event
} else {
// Ignore event
console.info("Event not supported => ignored");
return false;
}
this.events.push({
sender: m.sender,
id: m.event_id,
time: m.origin_server_ts,
data,
});
this.rebuildMessagesList();
return true;
}
private rebuildMessagesList() {
this.messages = [];
// Sorts events list to process oldest events first
this.events.sort((a, b) => a.time - b.time);
// Process receipts (users map)
const receiptsUsersMap = new Map<string, Receipt>();
for (const r of this.receipts) {
receiptsUsersMap.set(r.user, { ...r });
}
// First, process redactions to skip redacted events
const redacted = new Set(
this.events
.map((e) =>
e.data.type === "m.room.redaction" ? e.data.redacts : undefined
)
.filter((e) => e !== undefined)
);
for (const evt of this.events) {
if (redacted.has(evt.id)) continue;
const data = evt.data;
// Message
if (data.type === "m.room.message") {
// Check if this message replaces another one
if (data.content["m.relates_to"]?.rel_type === "replace") {
const message = this.messages.find(
(m) => m.event_id === data.content["m.relates_to"]?.event_id
);
if (!message) continue;
message.modified = true;
message.content = data.content.body;
continue;
}
// Else it is a new message; update receipts if needed
else {
const userReceipt = receiptsUsersMap.get(evt.sender);
// Create fake receipt if none is available
if (!userReceipt)
receiptsUsersMap.set(evt.sender, {
event_id: evt.id,
ts: evt.time,
user: evt.sender,
});
// If the message is more recent than user receipt, replace the receipt
else if (userReceipt.ts < evt.time) {
userReceipt.event_id = evt.id;
userReceipt.ts = evt.time;
}
}
this.messages.push({
event_id: evt.id,
account: evt.sender,
modified: false,
inReplyTo: data.content["m.relates_to"]?.["m.in_reply_to"]?.event_id,
reactions: new Map(),
time_sent: evt.time,
time_sent_dayjs: dayjs.unix(evt.time / 1000),
type: data.content.msgtype,
file: data.content.file?.url ?? data.content.url,
content: data.content.body,
});
}
// Reaction
if (data.type === "m.reaction") {
const message = this.messages.find(
(m) => m.event_id === data.content["m.relates_to"].event_id
);
const key = data.content["m.relates_to"].key;
if (!message) continue;
if (!message.reactions.has(key)) message.reactions.set(key, []);
message.reactions.get(key)!.push({
account: evt.sender,
event_id: evt.id,
key,
});
}
}
// Adapt receipts to be event-indexed
this.receiptsEventsMap.clear();
for (const receipt of [...receiptsUsersMap.values()]) {
if (!this.receiptsEventsMap.has(receipt.event_id))
this.receiptsEventsMap.set(receipt.event_id, [receipt]);
else this.receiptsEventsMap.get(receipt.event_id)!.push(receipt);
}
}
}

View File

@@ -1,5 +1,5 @@
import { Alert, Box, Button, CircularProgress } from "@mui/material"; import { Alert, Box, Button, CircularProgress } from "@mui/material";
import { useEffect, useRef, useState } from "react"; import React from "react";
const State = { const State = {
Loading: 0, Loading: 0,
@@ -10,16 +10,14 @@ const State = {
type State = keyof typeof State; type State = keyof typeof State;
export function AsyncWidget(p: { export function AsyncWidget(p: {
loadKey: any; loadKey: unknown;
load: () => Promise<void>; load: () => Promise<void>;
errMsg: string; errMsg: string;
build: () => React.ReactElement; build: () => React.ReactElement;
ready?: boolean; ready?: boolean;
errAdditionalElement?: () => React.ReactElement; errAdditionalElement?: () => React.ReactElement;
}): React.ReactElement { }): React.ReactElement {
const [state, setState] = useState<number>(State.Loading); const [state, setState] = React.useState<number>(State.Loading);
const counter = useRef<any>(null);
const load = async () => { const load = async () => {
try { try {
@@ -32,12 +30,10 @@ export function AsyncWidget(p: {
} }
}; };
useEffect(() => { React.useEffect(() => {
if (counter.current === p.loadKey) return;
counter.current = p.loadKey;
load(); load();
}); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [p.loadKey]);
if (state === State.Error) if (state === State.Error)
return ( return (

View File

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

View File

@@ -0,0 +1,31 @@
import { Emoji, EmojiStyle } from "emoji-picker-react";
function emojiUnicode(emoji: string): string {
let comp;
if (emoji.length === 1) {
comp = emoji.charCodeAt(0);
}
comp =
(emoji.charCodeAt(0) - 0xd800) * 0x400 +
(emoji.charCodeAt(1) - 0xdc00) +
0x10000;
if (comp < 0) {
comp = emoji.charCodeAt(0);
}
const s = comp.toString(16);
return s.includes("f") ? s : `${s}-fe0f`;
}
export function EmojiIcon(p: {
emojiKey: string;
size?: number;
}): React.ReactElement {
const unified = emojiUnicode(p.emojiKey);
return (
<Emoji
unified={unified ?? ""}
emojiStyle={EmojiStyle.NATIVE}
size={p.size ?? 18}
/>
);
}

View File

@@ -1,3 +1,7 @@
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
import { Button } from "@mui/material";
import { Link } from "react-router";
export function NotLinkedAccountMessage(): React.ReactElement { export function NotLinkedAccountMessage(): React.ReactElement {
return ( return (
<div <div
@@ -6,9 +10,17 @@ export function NotLinkedAccountMessage(): React.ReactElement {
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
flexDirection: "column",
}} }}
> >
<div style={{ marginBottom: "50px" }}>
Your Matrix account is not linked yet! Your Matrix account is not linked yet!
</div> </div>
<Link to={"/matrix_link"}>
<Button variant="outlined" startIcon={<ArrowForwardIcon />}>
Go to Matrix Link settings
</Button>
</Link>
</div>
); );
} }

View File

@@ -0,0 +1,31 @@
import { Tooltip } from "@mui/material";
import {
formatDateTime,
formatDate,
timeDiff,
timeDiffFromNow,
} from "../utils/DateUtils";
export function TimeWidget(p: {
time?: number;
isDuration?: boolean;
showDate?: boolean;
}): React.ReactElement {
if (!p.time) return <></>;
return (
<Tooltip
title={formatDateTime(
p.isDuration ? new Date().getTime() / 1000 - p.time : p.time
)}
arrow
>
<span>
{p.showDate
? formatDate(p.time)
: p.isDuration
? timeDiff(0, p.time)
: timeDiffFromNow(p.time)}
</span>
</Tooltip>
);
}

View File

@@ -1,10 +1,10 @@
import { mdiMessageTextFast } from "@mdi/js";
import Icon from "@mdi/react";
import { Typography } from "@mui/material"; import { Typography } from "@mui/material";
import MuiCard from "@mui/material/Card"; import MuiCard from "@mui/material/Card";
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
import { styled } from "@mui/material/styles"; import { styled } from "@mui/material/styles";
import { Outlet } from "react-router"; import { Outlet } from "react-router";
import { AppIcon } from "../../icons/AppIcon";
import mdiMessageTextFast from "../../icons/message-text-fast.svg";
const Card = styled(MuiCard)(({ theme }) => ({ const Card = styled(MuiCard)(({ theme }) => ({
display: "flex", display: "flex",
@@ -57,12 +57,7 @@ export function BaseLoginPage(): React.ReactElement {
variant="h4" variant="h4"
sx={{ width: "100%", fontSize: "clamp(2rem, 10vw, 2.15rem)" }} sx={{ width: "100%", fontSize: "clamp(2rem, 10vw, 2.15rem)" }}
> >
<Icon <AppIcon src={mdiMessageTextFast} size={"2em"} /> MatrixGW
path={mdiMessageTextFast}
size={"1em"}
style={{ display: "inline-table" }}
/>{" "}
MatrixGW
</Typography> </Typography>
<Outlet /> <Outlet />
</Card> </Card>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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