mirror of
https://github.com/suitenumerique/docs.git
synced 2026-04-26 01:25:05 +02:00
Compare commits
641 Commits
feature/do
...
feat/e2e-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0144044c55 | ||
|
|
a6da37e231 | ||
|
|
9aeedd1d03 | ||
|
|
f7d4e6810b | ||
|
|
b740ffa52c | ||
|
|
f555e36e98 | ||
|
|
de11ab508f | ||
|
|
dc2fe4905b | ||
|
|
2864669dde | ||
|
|
7dae3a3c02 | ||
|
|
bdf62e2172 | ||
|
|
29104dfe2d | ||
|
|
785c9b21cf | ||
|
|
3fee1f2081 | ||
|
|
5f9968d81e | ||
|
|
f7baf238e3 | ||
|
|
bab42efd08 | ||
|
|
175d80db16 | ||
|
|
f8b8390758 | ||
|
|
a1463e0a10 | ||
|
|
0b555eed9f | ||
|
|
1bf810d596 | ||
|
|
48e1370ba3 | ||
|
|
b13571c6df | ||
|
|
a2a63cd13e | ||
|
|
3ebb62d786 | ||
|
|
0caee61d86 | ||
|
|
10a319881d | ||
|
|
26620f3471 | ||
|
|
0d0e17c8d5 | ||
|
|
257de6d068 | ||
|
|
5a4c02a978 | ||
|
|
0090ccc981 | ||
|
|
d403878f8c | ||
|
|
191b046641 | ||
|
|
aeac49d760 | ||
|
|
b5dcbbb057 | ||
|
|
2e64298ff4 | ||
|
|
8dad9ea6c4 | ||
|
|
3ae8046ffc | ||
|
|
a4e3168682 | ||
|
|
c8955133a4 | ||
|
|
b069310bf0 | ||
|
|
1292c33a58 | ||
|
|
bf68a5ae40 | ||
|
|
8799b4aa2f | ||
|
|
d96abb1ccf | ||
|
|
dc12a99d4a | ||
|
|
82a0c1a770 | ||
|
|
a758254b60 | ||
|
|
6314cb3a18 | ||
|
|
3e410e3519 | ||
|
|
aba7959344 | ||
|
|
3d45c7c215 | ||
|
|
cdb26b480a | ||
|
|
23a0f2761f | ||
|
|
0d596e338c | ||
|
|
3ab01c98c8 | ||
|
|
6445c05e29 | ||
|
|
b9b25eb1f6 | ||
|
|
de157b4f52 | ||
|
|
e5581e52f7 | ||
|
|
b91840c819 | ||
|
|
a9b77fb9a7 | ||
|
|
66f83db0e5 | ||
|
|
f9ff578c6b | ||
|
|
1372438f8e | ||
|
|
c5d5d3dec4 | ||
|
|
ad16c0843c | ||
|
|
78a6307656 | ||
|
|
d7d468f51f | ||
|
|
eb71028f6b | ||
|
|
39c22b074d | ||
|
|
d5c3f248a5 | ||
|
|
91217b3c4f | ||
|
|
ab271bc90d | ||
|
|
82e1783317 | ||
|
|
aa2b9ed5f2 | ||
|
|
1c96d645ba | ||
|
|
2f010cf36d | ||
|
|
9d3c1eb9d5 | ||
|
|
08f3ceaf3f | ||
|
|
b1d033edc9 | ||
|
|
192fa76b54 | ||
|
|
b667200ebd | ||
|
|
294922f966 | ||
|
|
8b73aa3644 | ||
|
|
dd56a8abeb | ||
|
|
145c688830 | ||
|
|
950d215632 | ||
|
|
7d5cc4e84b | ||
|
|
3e5bcf96ea | ||
|
|
fe24c00178 | ||
|
|
aca334f81f | ||
|
|
2003e41c22 | ||
|
|
5ebdf4b4d4 | ||
|
|
35e771a1ce | ||
|
|
2b5a9e1af8 | ||
|
|
a833fdc7a1 | ||
|
|
b3cc2bf833 | ||
|
|
18feab10cb | ||
|
|
2777488d24 | ||
|
|
a11258f778 | ||
|
|
33647f124f | ||
|
|
e339cda5c6 | ||
|
|
4ce65c654f | ||
|
|
c048b2ae95 | ||
|
|
5908afb098 | ||
|
|
e2298a3658 | ||
|
|
278eb233e9 | ||
|
|
b056dbfad4 | ||
|
|
771ef2417f | ||
|
|
8d5262c2f2 | ||
|
|
1125f441dc | ||
|
|
16f2de4c75 | ||
|
|
f19fa93600 | ||
|
|
af3d90db3b | ||
|
|
127c90ca5f | ||
|
|
fa7cf7a594 | ||
|
|
6523165ea0 | ||
|
|
de4d11732f | ||
|
|
37138c1a23 | ||
|
|
2c1a9ff74f | ||
|
|
31389bcae2 | ||
|
|
f772801fd0 | ||
|
|
390a615f48 | ||
|
|
5bdf5d2210 | ||
|
|
ed336558ac | ||
|
|
4fbd588198 | ||
|
|
546f97c956 | ||
|
|
af01c6e466 | ||
|
|
8023720da3 | ||
|
|
91eba31735 | ||
|
|
45d6c1beef | ||
|
|
dc25f3f39c | ||
|
|
529e7f1737 | ||
|
|
51c5c4ee63 | ||
|
|
72f098c667 | ||
|
|
3b08ba4de1 | ||
|
|
590b67fd71 | ||
|
|
b3980e7bf1 | ||
|
|
e3b2fdbdf5 | ||
|
|
314a7fa7b0 | ||
|
|
93227466d2 | ||
|
|
db7ae350ec | ||
|
|
236c8df5ae | ||
|
|
ae1b05189e | ||
|
|
431c331154 | ||
|
|
5184723862 | ||
|
|
ca10fb9a12 | ||
|
|
59e875764c | ||
|
|
7ed46ab225 | ||
|
|
18f4ab880f | ||
|
|
e71c45077d | ||
|
|
14c84f000e | ||
|
|
6cc42636e5 | ||
|
|
cc4bed6f8e | ||
|
|
d8f90c04bd | ||
|
|
1fdf70bdcf | ||
|
|
8ab21ef00d | ||
|
|
f337a2a8f2 | ||
|
|
3607faa475 | ||
|
|
0ea7dd727f | ||
|
|
6aca40a034 | ||
|
|
ee3b05cb55 | ||
|
|
c23ff546d8 | ||
|
|
a751f1255a | ||
|
|
8ee50631f3 | ||
|
|
e5e5fba0b3 | ||
|
|
0894bcdca5 | ||
|
|
75da342058 | ||
|
|
1ed01fd64b | ||
|
|
e4aa85be83 | ||
|
|
2dc1e07b42 | ||
|
|
fbdeb90113 | ||
|
|
b773f09792 | ||
|
|
d8c9283dd1 | ||
|
|
1e39d17914 | ||
|
|
ecd2f97cf5 | ||
|
|
90624e83f5 | ||
|
|
5fc002658c | ||
|
|
dfd5dc1545 | ||
|
|
69e7235f75 | ||
|
|
942c90c29f | ||
|
|
c5f0142671 | ||
|
|
7f37d3bda4 | ||
|
|
7033d0ecf7 | ||
|
|
0dd6818e91 | ||
|
|
eb225fc86f | ||
|
|
b893a29138 | ||
|
|
a812580d6c | ||
|
|
1062e38c92 | ||
|
|
62e122b05f | ||
|
|
32bc2890e0 | ||
|
|
3c3686dc7e | ||
|
|
ab90611c36 | ||
|
|
f9c08cf5ec | ||
|
|
2155c2ff1f | ||
|
|
ef08ba3a00 | ||
|
|
7a903041f8 | ||
|
|
4f2e07f949 | ||
|
|
8c1e95c587 | ||
|
|
20161fd6db | ||
|
|
e827cfeee1 | ||
|
|
eab2a75bff | ||
|
|
cd84751cb9 | ||
|
|
1d20a8b0a7 | ||
|
|
8a310d004b | ||
|
|
9f9fae96e5 | ||
|
|
9cb2b6a6fb | ||
|
|
0a1eaa3c40 | ||
|
|
da72a1601a | ||
|
|
9a51e02cd7 | ||
|
|
4184c339eb | ||
|
|
3688591dd1 | ||
|
|
25783182b8 | ||
|
|
80a62bcbc1 | ||
|
|
ede0a77665 | ||
|
|
8a8a1460e5 | ||
|
|
0ac9f059b6 | ||
|
|
179a84150b | ||
|
|
084d0c1089 | ||
|
|
c9a6c4d4c6 | ||
|
|
9db7d0af8d | ||
|
|
9135dff088 | ||
|
|
cc4c67d15b | ||
|
|
63a2bde11e | ||
|
|
b317a2a596 | ||
|
|
39ef6d10ff | ||
|
|
961ae3c39e | ||
|
|
726b50d6b5 | ||
|
|
814eb1f1a1 | ||
|
|
648528499c | ||
|
|
474e5ac0c0 | ||
|
|
a799d77643 | ||
|
|
2e04b63d2d | ||
|
|
eec419bdba | ||
|
|
baa5630344 | ||
|
|
e7b551caa4 | ||
|
|
4dfc1584bd | ||
|
|
09eddfc339 | ||
|
|
75f2e547e0 | ||
|
|
d1cbdfd819 | ||
|
|
0b64417058 | ||
|
|
57a505a80c | ||
|
|
21ee38c218 | ||
|
|
09de014a43 | ||
|
|
8d42149304 | ||
|
|
2451a6a322 | ||
|
|
d5c9eaca5a | ||
|
|
1491012969 | ||
|
|
9dcf478dd3 | ||
|
|
586825aafa | ||
|
|
247550fc13 | ||
|
|
781c85b66b | ||
|
|
64f967cd29 | ||
|
|
1eee24dc19 | ||
|
|
ff9e13ca03 | ||
|
|
7758e64f40 | ||
|
|
4ab9edcd57 | ||
|
|
0892c05321 | ||
|
|
2375bc136c | ||
|
|
e1c2053697 | ||
|
|
58f68d86e1 | ||
|
|
7c97719907 | ||
|
|
d0c9de9d96 | ||
|
|
81f3997628 | ||
|
|
0cf8b9da1a | ||
|
|
7be761ce84 | ||
|
|
5181bba083 | ||
|
|
f434d78b5d | ||
|
|
e07f709dd4 | ||
|
|
afbacb0a24 | ||
|
|
409e073192 | ||
|
|
886dcb75d5 | ||
|
|
bb4d2a9fea | ||
|
|
5e5054282e | ||
|
|
f497e75426 | ||
|
|
97ab13ded6 | ||
|
|
99d674c615 | ||
|
|
1cdb6b62c8 | ||
|
|
2bf53301d2 | ||
|
|
ec84f31bc7 | ||
|
|
7813219b86 | ||
|
|
cecb4f5756 | ||
|
|
63efe40a7b | ||
|
|
e26c3dff35 | ||
|
|
f5f9d8a877 | ||
|
|
e7709badbb | ||
|
|
2a7c0ef800 | ||
|
|
155e7dfe22 | ||
|
|
afa48b6675 | ||
|
|
f12d30cffa | ||
|
|
30dfea744a | ||
|
|
2cbe363a5f | ||
|
|
7f450e8aa8 | ||
|
|
7021c0f849 | ||
|
|
e8d18d85e9 | ||
|
|
67a195f89c | ||
|
|
09b6fef63f | ||
|
|
11d0bafc94 | ||
|
|
1ae831cabd | ||
|
|
f1c2219270 | ||
|
|
8c9380c356 | ||
|
|
3ff6d2541c | ||
|
|
34ce276222 | ||
|
|
04273c3b3e | ||
|
|
0b301b95c8 | ||
|
|
228bdf733e | ||
|
|
bbf48f088f | ||
|
|
b28ff8f632 | ||
|
|
14b7cdf561 | ||
|
|
c534fed196 | ||
|
|
c1a740b7d4 | ||
|
|
83f2b3886e | ||
|
|
966e514c5a | ||
|
|
ef6d6c6a59 | ||
|
|
e79f3281b1 | ||
|
|
b78550b513 | ||
|
|
5a23c97681 | ||
|
|
040eddbe6b | ||
|
|
f2e54308d2 | ||
|
|
cd6e0ef9e1 | ||
|
|
02acc7233f | ||
|
|
1c71e830a2 | ||
|
|
ac0c16a44a | ||
|
|
ca09f9a158 | ||
|
|
d12b608db9 | ||
|
|
08a0eb59c8 | ||
|
|
0afc50fb93 | ||
|
|
c48a4309c1 | ||
|
|
a212417fb8 | ||
|
|
500d4ea5ac | ||
|
|
8a057b9c39 | ||
|
|
6a12ac560e | ||
|
|
2e6cb109ef | ||
|
|
70635136cb | ||
|
|
52a8dd0b5c | ||
|
|
8a3dfe0252 | ||
|
|
1110ec92d5 | ||
|
|
1d01f6512e | ||
|
|
cd366213ca | ||
|
|
d15285d385 | ||
|
|
377d4e8971 | ||
|
|
70f0c7052c | ||
|
|
ca2e02806a | ||
|
|
33bd5ef116 | ||
|
|
7abe1c9eb4 | ||
|
|
95838e332c | ||
|
|
82f2cb59e6 | ||
|
|
44909faa67 | ||
|
|
1c5270e301 | ||
|
|
6af8d78ede | ||
|
|
304b3be273 | ||
|
|
17ece3b715 | ||
|
|
510d6c3ff1 | ||
|
|
cab7771b82 | ||
|
|
93d9dec068 | ||
|
|
adb15dedb8 | ||
|
|
6ece3264d6 | ||
|
|
2a3b31fcff | ||
|
|
9a64ebc1e9 | ||
|
|
cb2ecfcea3 | ||
|
|
13696ffbd7 | ||
|
|
40ed2d2e22 | ||
|
|
ecb20f6f77 | ||
|
|
7bc060988d | ||
|
|
122e510ff4 | ||
|
|
f717a39109 | ||
|
|
04b8400766 | ||
|
|
d232654c55 | ||
|
|
d0eb2275e5 | ||
|
|
50faf766c8 | ||
|
|
433cead0ac | ||
|
|
d12c637dad | ||
|
|
184b5c015b | ||
|
|
1ab237af3b | ||
|
|
f782a0236b | ||
|
|
c1fc1bd52f | ||
|
|
1c34305393 | ||
|
|
611ba496d2 | ||
|
|
0a9a583a67 | ||
|
|
8f67e382ba | ||
|
|
18d46acd75 | ||
|
|
fae024229e | ||
|
|
df2b953e53 | ||
|
|
a7c91f9443 | ||
|
|
0a5887c162 | ||
|
|
26c7af0dbf | ||
|
|
0499aec624 | ||
|
|
21624e9224 | ||
|
|
b0a9ce0938 | ||
|
|
e256017628 | ||
|
|
50ce604ade | ||
|
|
55979e4370 | ||
|
|
9a8f952210 | ||
|
|
118804e810 | ||
|
|
651f2d1d75 | ||
|
|
b96de36382 | ||
|
|
65b6701708 | ||
|
|
0be366b7b6 | ||
|
|
78a6772bab | ||
|
|
fde520a6f3 | ||
|
|
cef2d274fc | ||
|
|
a9db392a61 | ||
|
|
186ae952f5 | ||
|
|
f3c9c41b86 | ||
|
|
58bf5071c2 | ||
|
|
e148c237f1 | ||
|
|
e82e6a1fcf | ||
|
|
fc1678d0c2 | ||
|
|
2b2e81f042 | ||
|
|
c8ae2f6549 | ||
|
|
1d741871d7 | ||
|
|
6c3850b22b | ||
|
|
31e8ed3a00 | ||
|
|
7e63e9e460 | ||
|
|
388f71d9d0 | ||
|
|
2360a832af | ||
|
|
411d52c73b | ||
|
|
394f91387d | ||
|
|
878de08b1e | ||
|
|
d33286019c | ||
|
|
c2e46fa9e2 | ||
|
|
2e1b112133 | ||
|
|
8f7ac12ea1 | ||
|
|
dfdfe83db5 | ||
|
|
4ae757ce93 | ||
|
|
6964686f7c | ||
|
|
45bbffdf9f | ||
|
|
95a55e7805 | ||
|
|
e9ac36e811 | ||
|
|
d8294ee11d | ||
|
|
00009ecc16 | ||
|
|
9b0676ec15 | ||
|
|
9f222bbaa3 | ||
|
|
f0b253f0ff | ||
|
|
1e76e6e04c | ||
|
|
a71453206b | ||
|
|
71cd016d4d | ||
|
|
2a7ffff96d | ||
|
|
ff8275fb4e | ||
|
|
c3f81c2b62 | ||
|
|
c7261cf507 | ||
|
|
e504f43611 | ||
|
|
3ad6d0ea12 | ||
|
|
9e8a7b3502 | ||
|
|
05db9c8e51 | ||
|
|
7ed33019c2 | ||
|
|
a99c813421 | ||
|
|
a83902a0d4 | ||
|
|
080f855083 | ||
|
|
90d94f6b7a | ||
|
|
f97ab51c8e | ||
|
|
ba4f90a607 | ||
|
|
6c16e081de | ||
|
|
56a945983e | ||
|
|
4fbbead405 | ||
|
|
9a212400a0 | ||
|
|
f07fcd4c0d | ||
|
|
4fc49d5cb2 | ||
|
|
0fd16b4371 | ||
|
|
fbb2799050 | ||
|
|
afbb4b29dc | ||
|
|
db63ebd0c8 | ||
|
|
c5f018e03e | ||
|
|
1c93fbc007 | ||
|
|
d811e3c2fc | ||
|
|
fe5fda5d73 | ||
|
|
bf66265125 | ||
|
|
ce329142dc | ||
|
|
f8cff43dac | ||
|
|
f5b2c27bd8 | ||
|
|
62433ef7f1 | ||
|
|
bc0824d110 | ||
|
|
fa653c6776 | ||
|
|
d12f942d29 | ||
|
|
62f85e7d24 | ||
|
|
65cc088a17 | ||
|
|
94e99784f3 | ||
|
|
fa83955a77 | ||
|
|
5962f7aae1 | ||
|
|
dc06315566 | ||
|
|
f4ad26a8fa | ||
|
|
d952815932 | ||
|
|
cde64ed80a | ||
|
|
cfd88d0469 | ||
|
|
5e45fec296 | ||
|
|
393e7a06e2 | ||
|
|
f1af87baf8 | ||
|
|
f851ef2d85 | ||
|
|
252ab6a586 | ||
|
|
cf2a02c8de | ||
|
|
d87a2ed4eb | ||
|
|
c9d053d1c0 | ||
|
|
b5f0f06ea3 | ||
|
|
36b0ff9f63 | ||
|
|
7a383957a7 | ||
|
|
b5630359ee | ||
|
|
310154815b | ||
|
|
2733785016 | ||
|
|
99ba414d88 | ||
|
|
41631b5b70 | ||
|
|
6ca654bf1a | ||
|
|
074585337b | ||
|
|
f1b398e1ae | ||
|
|
d1f73f18cd | ||
|
|
3f2d84bf62 | ||
|
|
7b9c362d38 | ||
|
|
bf999979d2 | ||
|
|
09d3ff3754 | ||
|
|
6e5d005dee | ||
|
|
6377c8fcca | ||
|
|
3c8cacc048 | ||
|
|
598fb4fa27 | ||
|
|
51618ad081 | ||
|
|
8109d5ba08 | ||
|
|
e4d0179bbe | ||
|
|
9d3dfb6de7 | ||
|
|
0da042f887 | ||
|
|
6cd0cd0689 | ||
|
|
10b088599c | ||
|
|
62d1bc6473 | ||
|
|
fc1d33268c | ||
|
|
95833fa5ec | ||
|
|
dd6e0b5072 | ||
|
|
95d3a8cd18 | ||
|
|
4f126ab824 | ||
|
|
fb90c13dad | ||
|
|
4118d79525 | ||
|
|
5848f43cb4 | ||
|
|
4b0fd223c8 | ||
|
|
31d0733851 | ||
|
|
16e20e984c | ||
|
|
76c28760dc | ||
|
|
d856abb5d8 | ||
|
|
25abd964de | ||
|
|
a070e1dd87 | ||
|
|
37d9ae8cca | ||
|
|
29ea6b8ef7 | ||
|
|
a692fa6f39 | ||
|
|
4d541c5d52 | ||
|
|
e5f029ad1d | ||
|
|
bd79f84e07 | ||
|
|
a070f56339 | ||
|
|
02478acb3f | ||
|
|
23aa497db0 | ||
|
|
d48436bffb | ||
|
|
41e4c45934 | ||
|
|
6be87ed477 | ||
|
|
c96182b3e3 | ||
|
|
e79d1d618a | ||
|
|
2691cdd4a2 | ||
|
|
05a1390bdc | ||
|
|
dfe8ae14fe | ||
|
|
74165f6890 | ||
|
|
349cbf8eb3 | ||
|
|
12ef1a2450 | ||
|
|
9b2f7966f6 | ||
|
|
5ad30b404d | ||
|
|
12524f35b7 | ||
|
|
f8a40cf8cc | ||
|
|
c32fdb67ac | ||
|
|
7f2a21cdc9 | ||
|
|
4ad917906c | ||
|
|
9ca79688c9 | ||
|
|
7f0eb9117e | ||
|
|
2557c6bc77 | ||
|
|
df173c3ce6 | ||
|
|
b58c991c81 | ||
|
|
96f6aeea60 | ||
|
|
9465f1a6ec | ||
|
|
98f11ff8ac | ||
|
|
b29daa2d77 | ||
|
|
5cdbdbf215 | ||
|
|
5268699d50 | ||
|
|
cdafe6fd33 | ||
|
|
4307b4f433 | ||
|
|
3bf33d202a | ||
|
|
101cef7d70 | ||
|
|
419079ac69 | ||
|
|
ecd06560c6 | ||
|
|
e9ab099ce0 | ||
|
|
67b69d05f7 | ||
|
|
f429eb053a | ||
|
|
ad11b7f554 | ||
|
|
3d5adad227 | ||
|
|
de8e812f2f | ||
|
|
7a1601c682 | ||
|
|
0537572542 | ||
|
|
8aab007ad1 | ||
|
|
cde3de43f7 | ||
|
|
8c0c3c2f44 | ||
|
|
c11d59c434 | ||
|
|
8836109945 | ||
|
|
ba136ff82f | ||
|
|
96d9d1a184 | ||
|
|
771ffdc7cc | ||
|
|
82eba1e8ea | ||
|
|
8c42599d0f | ||
|
|
8620cf4857 | ||
|
|
2a7da73248 | ||
|
|
e8e9922832 | ||
|
|
2da4ce4570 | ||
|
|
50b90f9ae7 | ||
|
|
65ddf7fbe8 | ||
|
|
d3a7ee74b3 | ||
|
|
65e450c6cc | ||
|
|
725cae5470 | ||
|
|
3881930e82 | ||
|
|
910686293c | ||
|
|
7e7c9ac4c5 | ||
|
|
d5d2cfab8e | ||
|
|
f2ed8e0ea1 | ||
|
|
fbe8a26dba | ||
|
|
3e974be9f4 | ||
|
|
10f9d25920 | ||
|
|
4178693e63 | ||
|
|
53be6de5f8 | ||
|
|
4ff90abdee | ||
|
|
544dd00c16 | ||
|
|
a3cd4c51ea | ||
|
|
7e1eed3abd | ||
|
|
8bee476b5b | ||
|
|
e86919fb9a | ||
|
|
a5b9169eb6 | ||
|
|
c0dfb4b6b3 | ||
|
|
be051ad7d2 | ||
|
|
a4452784e1 | ||
|
|
2929e98260 | ||
|
|
a1914c6259 | ||
|
|
c882f1386c | ||
|
|
c02f19a2cd | ||
|
|
34a208a80d | ||
|
|
6976bb7c78 | ||
|
|
621393165f | ||
|
|
3e9b530985 | ||
|
|
54f9b3963e | ||
|
|
710bbf512c | ||
|
|
747ca70186 | ||
|
|
9374495fda |
@@ -34,3 +34,4 @@ db.sqlite3
|
|||||||
|
|
||||||
# Frontend
|
# Frontend
|
||||||
node_modules
|
node_modules
|
||||||
|
.next
|
||||||
|
|||||||
23
.gitattributes
vendored
Normal file
23
.gitattributes
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Set the default behavior for all files
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# Binary files (should not be modified)
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.ico binary
|
||||||
|
*.mov binary
|
||||||
|
*.mp4 binary
|
||||||
|
*.mp3 binary
|
||||||
|
*.flv binary
|
||||||
|
*.fla binary
|
||||||
|
*.swf binary
|
||||||
|
*.gz binary
|
||||||
|
*.zip binary
|
||||||
|
*.7z binary
|
||||||
|
*.ttf binary
|
||||||
|
*.woff binary
|
||||||
|
*.woff2 binary
|
||||||
|
*.eot binary
|
||||||
|
*.pdf binary
|
||||||
6
.github/ISSUE_TEMPLATE.md
vendored
6
.github/ISSUE_TEMPLATE.md
vendored
@@ -1,6 +0,0 @@
|
|||||||
<!---
|
|
||||||
Thanks for filing an issue 😄 ! Before you submit, please read the following:
|
|
||||||
|
|
||||||
Check the other issue templates if you are trying to submit a bug report, feature request, or question
|
|
||||||
Search open/closed issues before submitting since someone might have asked the same thing before!
|
|
||||||
-->
|
|
||||||
10
.github/ISSUE_TEMPLATE/Bug_report.md
vendored
10
.github/ISSUE_TEMPLATE/Bug_report.md
vendored
@@ -1,11 +1,15 @@
|
|||||||
---
|
---
|
||||||
name: 🐛 Bug Report
|
name: 🐛 Bug Report
|
||||||
about: If something is not working as expected 🤔.
|
about: If something is not working as expected 🤔.
|
||||||
|
labels: ["bug", "triage"]
|
||||||
---
|
---
|
||||||
|
|
||||||
## Bug Report
|
## Bug Report
|
||||||
|
|
||||||
|
**Before you file your issue**
|
||||||
|
- Check the other [issues](https://github.com/suitenumerique/docs/issues) before filing your own
|
||||||
|
- If your report is related to the ([BlockNote](https://github.com/TypeCellOS/BlockNote)) text editor, [file it on their repo](https://github.com/TypeCellOS/BlockNote/issues). If you're not sure whether your issue is with BlockNote or Docs, file it on our repo: if we support it, we'll backport it upstream ourselves 😊, otherwise we'll ask you to do so.
|
||||||
|
|
||||||
**Problematic behavior**
|
**Problematic behavior**
|
||||||
A clear and concise description of the behavior.
|
A clear and concise description of the behavior.
|
||||||
|
|
||||||
@@ -18,8 +22,8 @@ A clear and concise description of what you expected to happen (or code).
|
|||||||
3. And then the bug happens!
|
3. And then the bug happens!
|
||||||
|
|
||||||
**Environment**
|
**Environment**
|
||||||
- Impress version:
|
- Docs version:
|
||||||
- Platform:
|
- Instance url:
|
||||||
|
|
||||||
**Possible Solution**
|
**Possible Solution**
|
||||||
<!--- Only if you have suggestions on a fix for the bug -->
|
<!--- Only if you have suggestions on a fix for the bug -->
|
||||||
|
|||||||
6
.github/ISSUE_TEMPLATE/Feature_request.md
vendored
6
.github/ISSUE_TEMPLATE/Feature_request.md
vendored
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: ✨ Feature Request
|
name: ✨ Feature Request
|
||||||
about: I have a suggestion (and may want to build it 💪)!
|
about: I have a suggestion (and may want to build it 💪)!
|
||||||
|
labels: ["feature", "triage"]
|
||||||
---
|
---
|
||||||
|
|
||||||
## Feature Request
|
## Feature Request
|
||||||
@@ -16,8 +16,8 @@ A clear and concise description of what you want to happen. Add any considered d
|
|||||||
A clear and concise description of any alternative solutions or features you've considered.
|
A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
|
||||||
**Discovery, Documentation, Adoption, Migration Strategy**
|
**Discovery, Documentation, Adoption, Migration Strategy**
|
||||||
If you can, explain how users will be able to use this and possibly write out a version the docs (if applicable).
|
If you can, explain how users will be able to use this and possibly write out some documentation (if applicable).
|
||||||
Maybe a screenshot or design?
|
Maybe add a screenshot or design?
|
||||||
|
|
||||||
**Do you want to work on it through a Pull Request?**
|
**Do you want to work on it through a Pull Request?**
|
||||||
<!-- Make sure to coordinate with us before you spend too much time working on an implementation! -->
|
<!-- Make sure to coordinate with us before you spend too much time working on an implementation! -->
|
||||||
|
|||||||
14
.github/ISSUE_TEMPLATE/Support_question.md
vendored
14
.github/ISSUE_TEMPLATE/Support_question.md
vendored
@@ -1,17 +1,13 @@
|
|||||||
---
|
---
|
||||||
name: 🤗 Support Question
|
name: 🤗 Support Question
|
||||||
about: If you have a question 💬, or something was not clear from the docs!
|
about: If you have a question 💬, or something was not clear from the docs!
|
||||||
|
labels: ["support", "triage"]
|
||||||
---
|
---
|
||||||
|
## Support request
|
||||||
|
**Checks before filing**
|
||||||
|
Please make sure you have read our [main Readme](https://github.com/suitenumerique/docs).
|
||||||
|
|
||||||
<!-- ^ Click "Preview" for a nicer view! ^
|
Also make sure it was not already answered in [an open or close issue](https://github.com/suitenumerique/docs/issues?q=is%3Aissue%20state%3Aopen%20label%3Asupport).
|
||||||
We primarily use GitHub as an issue tracker. If however you're encountering an issue not covered in the docs, we may be able to help! -->
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Please make sure you have read our [main Readme](https://github.com/numerique-gouv/impress).
|
|
||||||
|
|
||||||
Also make sure it was not already answered in [an open or close issue](https://github.com/numerique-gouv/impress/issues).
|
|
||||||
|
|
||||||
If your question was not covered, and you feel like it should be, fire away! We'd love to improve our docs! 👌
|
If your question was not covered, and you feel like it should be, fire away! We'd love to improve our docs! 👌
|
||||||
|
|
||||||
|
|||||||
19
.github/PULL_REQUEST_TEMPLATE.md
vendored
19
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,11 +1,22 @@
|
|||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
Description...
|
Describe the purpose of this pull request.
|
||||||
|
|
||||||
|
|
||||||
## Proposal
|
## Proposal
|
||||||
|
|
||||||
Description...
|
- [ ] item 1...
|
||||||
|
- [ ] item 2...
|
||||||
|
|
||||||
- [] item 1...
|
## External contributions
|
||||||
- [] item 2...
|
|
||||||
|
Thank you for your contribution! 🎉
|
||||||
|
|
||||||
|
Please ensure the following items are checked before submitting your pull request:
|
||||||
|
- [ ] I have read and followed the [contributing guidelines](https://github.com/suitenumerique/docs/blob/main/CONTRIBUTING.md)
|
||||||
|
- [ ] I have read and agreed to the [Code of Conduct](https://github.com/suitenumerique/docs/blob/main/CODE_OF_CONDUCT.md)
|
||||||
|
- [ ] I have signed off my commits with `git commit --signoff` (DCO compliance)
|
||||||
|
- [ ] I have signed my commits with my SSH or GPG key (`git commit -S`)
|
||||||
|
- [ ] My commit messages follow the required format: `<gitmoji>(type) title description`
|
||||||
|
- [ ] I have added a changelog entry under `## [Unreleased]` section (if noticeable change)
|
||||||
|
- [ ] I have added corresponding tests for new features or bug fixes (if applicable)
|
||||||
2
.github/workflows/crowdin_download.yml
vendored
2
.github/workflows/crowdin_download.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
|||||||
install-dependencies:
|
install-dependencies:
|
||||||
uses: ./.github/workflows/dependencies.yml
|
uses: ./.github/workflows/dependencies.yml
|
||||||
with:
|
with:
|
||||||
node_version: '20.x'
|
node_version: '22.x'
|
||||||
with-front-dependencies-installation: true
|
with-front-dependencies-installation: true
|
||||||
|
|
||||||
synchronize-with-crowdin:
|
synchronize-with-crowdin:
|
||||||
|
|||||||
7
.github/workflows/crowdin_upload.yml
vendored
7
.github/workflows/crowdin_upload.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
|||||||
install-dependencies:
|
install-dependencies:
|
||||||
uses: ./.github/workflows/dependencies.yml
|
uses: ./.github/workflows/dependencies.yml
|
||||||
with:
|
with:
|
||||||
node_version: '20.x'
|
node_version: '22.x'
|
||||||
with-front-dependencies-installation: true
|
with-front-dependencies-installation: true
|
||||||
with-build_mails: true
|
with-build_mails: true
|
||||||
|
|
||||||
@@ -23,9 +23,10 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
# Backend i18n
|
# Backend i18n
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@v3
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.12.6"
|
python-version: "3.13.3"
|
||||||
|
cache: "pip"
|
||||||
- name: Upgrade pip and setuptools
|
- name: Upgrade pip and setuptools
|
||||||
run: pip install --upgrade pip setuptools
|
run: pip install --upgrade pip setuptools
|
||||||
- name: Install development dependencies
|
- name: Install development dependencies
|
||||||
|
|||||||
2
.github/workflows/dependencies.yml
vendored
2
.github/workflows/dependencies.yml
vendored
@@ -5,7 +5,7 @@ on:
|
|||||||
inputs:
|
inputs:
|
||||||
node_version:
|
node_version:
|
||||||
required: false
|
required: false
|
||||||
default: '20.x'
|
default: '22.x'
|
||||||
type: string
|
type: string
|
||||||
with-front-dependencies-installation:
|
with-front-dependencies-installation:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
|||||||
49
.github/workflows/docker-hub.yml
vendored
49
.github/workflows/docker-hub.yml
vendored
@@ -11,6 +11,7 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- 'main'
|
- 'main'
|
||||||
|
- 'ci/trivy-fails'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
DOCKER_USER: 1001:127
|
DOCKER_USER: 1001:127
|
||||||
@@ -30,15 +31,17 @@ jobs:
|
|||||||
images: lasuite/impress-backend
|
images: lasuite/impress-backend
|
||||||
-
|
-
|
||||||
name: Login to DockerHub
|
name: Login to DockerHub
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
|
||||||
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
-
|
-
|
||||||
name: Run trivy scan
|
name: Run trivy scan
|
||||||
uses: numerique-gouv/action-trivy-cache@main
|
uses: numerique-gouv/action-trivy-cache@main
|
||||||
with:
|
with:
|
||||||
docker-build-args: '--target backend-production -f Dockerfile'
|
docker-build-args: '--target backend-production -f Dockerfile'
|
||||||
docker-image-name: 'docker.io/lasuite/impress-backend:${{ github.sha }}'
|
docker-image-name: 'docker.io/lasuite/impress-backend:${{ github.sha }}'
|
||||||
continue-on-error: true
|
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -46,7 +49,7 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
target: backend-production
|
target: backend-production
|
||||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
@@ -64,15 +67,17 @@ jobs:
|
|||||||
images: lasuite/impress-frontend
|
images: lasuite/impress-frontend
|
||||||
-
|
-
|
||||||
name: Login to DockerHub
|
name: Login to DockerHub
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
|
||||||
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
-
|
-
|
||||||
name: Run trivy scan
|
name: Run trivy scan
|
||||||
uses: numerique-gouv/action-trivy-cache@main
|
uses: numerique-gouv/action-trivy-cache@main
|
||||||
with:
|
with:
|
||||||
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
|
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
|
||||||
docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}'
|
docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}'
|
||||||
continue-on-error: true
|
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -80,8 +85,10 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
file: ./src/frontend/Dockerfile
|
file: ./src/frontend/Dockerfile
|
||||||
target: frontend-production
|
target: frontend-production
|
||||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
build-args: |
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||||
|
PUBLISH_AS_MIT=false
|
||||||
|
push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
@@ -99,15 +106,14 @@ jobs:
|
|||||||
images: lasuite/impress-y-provider
|
images: lasuite/impress-y-provider
|
||||||
-
|
-
|
||||||
name: Login to DockerHub
|
name: Login to DockerHub
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
|
||||||
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
||||||
-
|
-
|
||||||
name: Run trivy scan
|
name: Run trivy scan
|
||||||
uses: numerique-gouv/action-trivy-cache@main
|
uses: numerique-gouv/action-trivy-cache@main
|
||||||
with:
|
with:
|
||||||
docker-build-args: '-f src/frontend/servers/y-provider/Dockerfile --target y-provider'
|
docker-build-args: '-f src/frontend/servers/y-provider/Dockerfile --target y-provider'
|
||||||
docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}'
|
docker-image-name: 'docker.io/lasuite/impress-y-provider:${{ github.sha }}'
|
||||||
continue-on-error: true
|
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -116,7 +122,7 @@ jobs:
|
|||||||
file: ./src/frontend/servers/y-provider/Dockerfile
|
file: ./src/frontend/servers/y-provider/Dockerfile
|
||||||
target: y-provider
|
target: y-provider
|
||||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
@@ -125,14 +131,11 @@ jobs:
|
|||||||
- build-and-push-frontend
|
- build-and-push-frontend
|
||||||
- build-and-push-backend
|
- build-and-push-backend
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
|
||||||
steps:
|
steps:
|
||||||
-
|
- uses: numerique-gouv/action-argocd-webhook-notification@main
|
||||||
name: Checkout repository
|
id: notify
|
||||||
uses: actions/checkout@v4
|
with:
|
||||||
-
|
deployment_repo_path: "${{ secrets.DEPLOYMENT_REPO_URL }}"
|
||||||
name: Call argocd github webhook
|
argocd_webhook_secret: "${{ secrets.ARGOCD_PREPROD_WEBHOOK_SECRET }}"
|
||||||
run: |
|
argocd_url: "${{ vars.ARGOCD_PREPROD_WEBHOOK_URL }}"
|
||||||
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/${{ secrets.DEPLOYMENT_REPO_URL }}"}}'
|
|
||||||
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac "${{ secrets.ARGOCD_PREPROD_WEBHOOK_SECRET }}" | awk '{print "X-Hub-Signature: sha1="$2}')
|
|
||||||
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" ${{ vars.ARGOCD_PREPROD_WEBHOOK_URL }}
|
|
||||||
|
|||||||
6
.github/workflows/helmfile-linter.yaml
vendored
6
.github/workflows/helmfile-linter.yaml
vendored
@@ -21,10 +21,10 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
HELMFILE=src/helm/helmfile.yaml
|
HELMFILE=src/helm/helmfile.yaml.gotmpl
|
||||||
environments=$(awk 'BEGIN {in_env=0} /^environments:/ {in_env=1; next} /^---/ {in_env=0} in_env && /^ [^ ]/ {gsub(/^ /,""); gsub(/:.*$/,""); print}' "$HELMFILE")
|
environments=$(awk 'BEGIN {in_env=0} /^environments:/ {in_env=1; next} /^---/ {in_env=0} in_env && /^ [^ ]/ {gsub(/^ /,""); gsub(/:.*$/,""); print}' "$HELMFILE")
|
||||||
for env in $environments; do
|
for env in $environments; do
|
||||||
echo "################### $env lint ###################"
|
echo "################### $env lint ###################"
|
||||||
helmfile -e $env -f $HELMFILE lint || exit 1
|
helmfile -e $env lint -f $HELMFILE || exit 1
|
||||||
echo -e "\n"
|
echo -e "\n"
|
||||||
done
|
done
|
||||||
|
|||||||
71
.github/workflows/impress-frontend.yml
vendored
71
.github/workflows/impress-frontend.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
|||||||
install-dependencies:
|
install-dependencies:
|
||||||
uses: ./.github/workflows/dependencies.yml
|
uses: ./.github/workflows/dependencies.yml
|
||||||
with:
|
with:
|
||||||
node_version: '20.x'
|
node_version: '22.x'
|
||||||
with-front-dependencies-installation: true
|
with-front-dependencies-installation: true
|
||||||
|
|
||||||
test-front:
|
test-front:
|
||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20.x"
|
node-version: "22.x"
|
||||||
|
|
||||||
- name: Restore the frontend cache
|
- name: Restore the frontend cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -48,7 +48,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20.x"
|
node-version: "22.x"
|
||||||
- name: Restore the frontend cache
|
- name: Restore the frontend cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
@@ -70,7 +70,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20.x"
|
node-version: "22.x"
|
||||||
|
|
||||||
- name: Restore the frontend cache
|
- name: Restore the frontend cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -80,13 +80,13 @@ jobs:
|
|||||||
fail-on-cache-miss: true
|
fail-on-cache-miss: true
|
||||||
|
|
||||||
- name: Set e2e env variables
|
- name: Set e2e env variables
|
||||||
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist
|
run: cat env.d/development/common.e2e >> env.d/development/common.local
|
||||||
|
|
||||||
- name: Install Playwright Browsers
|
- name: Install Playwright Browsers
|
||||||
run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright chromium
|
run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright chromium
|
||||||
|
|
||||||
- name: Start Docker services
|
- name: Start Docker services
|
||||||
run: make bootstrap FLUSH_ARGS='--no-input' cache=
|
run: make bootstrap-e2e FLUSH_ARGS='--no-input'
|
||||||
|
|
||||||
- name: Run e2e tests
|
- name: Run e2e tests
|
||||||
run: cd src/frontend/ && yarn e2e:test --project='chromium'
|
run: cd src/frontend/ && yarn e2e:test --project='chromium'
|
||||||
@@ -101,7 +101,7 @@ jobs:
|
|||||||
test-e2e-other-browser:
|
test-e2e-other-browser:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: test-e2e-chromium
|
needs: test-e2e-chromium
|
||||||
timeout-minutes: 20
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -109,7 +109,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20.x"
|
node-version: "22.x"
|
||||||
|
|
||||||
- name: Restore the frontend cache
|
- name: Restore the frontend cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -119,13 +119,13 @@ jobs:
|
|||||||
fail-on-cache-miss: true
|
fail-on-cache-miss: true
|
||||||
|
|
||||||
- name: Set e2e env variables
|
- name: Set e2e env variables
|
||||||
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist
|
run: cat env.d/development/common.e2e >> env.d/development/common.local
|
||||||
|
|
||||||
- name: Install Playwright Browsers
|
- name: Install Playwright Browsers
|
||||||
run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright firefox webkit chromium
|
run: cd src/frontend/apps/e2e && yarn install --frozen-lockfile && yarn install-playwright firefox webkit chromium
|
||||||
|
|
||||||
- name: Start Docker services
|
- name: Start Docker services
|
||||||
run: make bootstrap FLUSH_ARGS='--no-input' cache=
|
run: make bootstrap-e2e FLUSH_ARGS='--no-input'
|
||||||
|
|
||||||
- name: Run e2e tests
|
- name: Run e2e tests
|
||||||
run: cd src/frontend/ && yarn e2e:test --project=firefox --project=webkit
|
run: cd src/frontend/ && yarn e2e:test --project=firefox --project=webkit
|
||||||
@@ -136,3 +136,54 @@ jobs:
|
|||||||
name: playwright-other-report
|
name: playwright-other-report
|
||||||
path: src/frontend/apps/e2e/report/
|
path: src/frontend/apps/e2e/report/
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
|
bundle-size-check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: install-dependencies
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
issues: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Detect relevant changes
|
||||||
|
id: changes
|
||||||
|
uses: dorny/paths-filter@v2
|
||||||
|
with:
|
||||||
|
filters: |
|
||||||
|
lock:
|
||||||
|
- 'src/frontend/**/yarn.lock'
|
||||||
|
app:
|
||||||
|
- 'src/frontend/apps/impress/**'
|
||||||
|
|
||||||
|
- name: Restore the frontend cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: "src/frontend/**/node_modules"
|
||||||
|
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||||
|
fail-on-cache-miss: true
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
if: steps.changes.outputs.lock == 'true' || steps.changes.outputs.app == 'true'
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22.x"
|
||||||
|
|
||||||
|
- name: Check bundle size changes
|
||||||
|
if: steps.changes.outputs.lock == 'true' || steps.changes.outputs.app == 'true'
|
||||||
|
uses: preactjs/compressed-size-action@v2
|
||||||
|
with:
|
||||||
|
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||||
|
build-script: "app:build"
|
||||||
|
pattern: "apps/impress/out/**/*.{css,js,html}"
|
||||||
|
exclude: "{**/*.map,**/node_modules/**}"
|
||||||
|
minimum-change-threshold: 500
|
||||||
|
compression: "gzip"
|
||||||
|
cwd: "./src/frontend"
|
||||||
|
show-total: true
|
||||||
|
strip-hash: "[-_.][a-f0-9]{8,}(?=\\.(?:js|css|html)$)"
|
||||||
|
omit-unchanged: true
|
||||||
|
install-script: "yarn install --frozen-lockfile"
|
||||||
|
|||||||
42
.github/workflows/impress.yml
vendored
42
.github/workflows/impress.yml
vendored
@@ -19,20 +19,24 @@ jobs:
|
|||||||
if: github.event_name == 'pull_request' # Makes sense only for pull requests
|
if: github.event_name == 'pull_request' # Makes sense only for pull requests
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: show
|
- name: show
|
||||||
run: git log
|
run: git log
|
||||||
- name: Enforce absence of print statements in code
|
- name: Enforce absence of print statements in code
|
||||||
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/impress.yml' | grep "print("
|
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/impress.yml' | grep "print("
|
||||||
- name: Check absence of fixup commits
|
- name: Check absence of fixup commits
|
||||||
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
! git log | grep 'fixup!'
|
! git log | grep 'fixup!'
|
||||||
- name: Install gitlint
|
- name: Install gitlint
|
||||||
|
if: always()
|
||||||
run: pip install --user requests gitlint
|
run: pip install --user requests gitlint
|
||||||
- name: Lint commit messages added to main
|
- name: Lint commit messages added to main
|
||||||
|
if: always()
|
||||||
run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
|
run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
|
||||||
|
|
||||||
check-changelog:
|
check-changelog:
|
||||||
@@ -42,7 +46,7 @@ jobs:
|
|||||||
github.event_name == 'pull_request'
|
github.event_name == 'pull_request'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 50
|
fetch-depth: 50
|
||||||
- name: Check that the CHANGELOG has been modified in the current branch
|
- name: Check that the CHANGELOG has been modified in the current branch
|
||||||
@@ -52,7 +56,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4
|
||||||
- name: Check CHANGELOG max line length
|
- name: Check CHANGELOG max line length
|
||||||
run: |
|
run: |
|
||||||
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
|
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
|
||||||
@@ -61,6 +65,26 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
lint-spell-mistakes:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Install codespell
|
||||||
|
run: pip install --user codespell
|
||||||
|
- name: Check for typos
|
||||||
|
run: |
|
||||||
|
codespell \
|
||||||
|
--check-filenames \
|
||||||
|
--ignore-words-list "Dokument,afterAll,excpt,statics" \
|
||||||
|
--skip "./git/" \
|
||||||
|
--skip "**/*.pdf" \
|
||||||
|
--skip "**/*.po" \
|
||||||
|
--skip "**/*.pot" \
|
||||||
|
--skip "**/*.json" \
|
||||||
|
--skip "**/yarn.lock"
|
||||||
|
|
||||||
lint-back:
|
lint-back:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
@@ -68,11 +92,12 @@ jobs:
|
|||||||
working-directory: src/backend
|
working-directory: src/backend
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@v3
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.12.6"
|
python-version: "3.13.3"
|
||||||
|
cache: "pip"
|
||||||
- name: Upgrade pip and setuptools
|
- name: Upgrade pip and setuptools
|
||||||
run: pip install --upgrade pip setuptools
|
run: pip install --upgrade pip setuptools
|
||||||
- name: Install development dependencies
|
- name: Install development dependencies
|
||||||
@@ -165,9 +190,10 @@ jobs:
|
|||||||
mc version enable impress/impress-media-storage"
|
mc version enable impress/impress-media-storage"
|
||||||
|
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@v3
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.12.6"
|
python-version: "3.13.3"
|
||||||
|
cache: "pip"
|
||||||
|
|
||||||
- name: Install development dependencies
|
- name: Install development dependencies
|
||||||
run: pip install --user .[dev]
|
run: pip install --user .[dev]
|
||||||
|
|||||||
27
.github/workflows/label_preview.yml
vendored
Normal file
27
.github/workflows/label_preview.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
name: Label Preview
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [labeled, opened]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
comment:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: contains(github.event.pull_request.labels.*.name, 'preview')
|
||||||
|
steps:
|
||||||
|
- uses: thollander/actions-comment-pull-request@v3
|
||||||
|
with:
|
||||||
|
message: |
|
||||||
|
:rocket: Preview will be available at [https://${{ github.event.pull_request.number }}-docs.ppr-docs.beta.numerique.gouv.fr/](https://${{ github.event.pull_request.number }}-docs.ppr-docs.beta.numerique.gouv.fr/)
|
||||||
|
|
||||||
|
You can use the existing account with these credentials:
|
||||||
|
- username: `docs`
|
||||||
|
- password: `docs`
|
||||||
|
|
||||||
|
You can also create a new account if you want to.
|
||||||
|
|
||||||
|
Once this Pull Request is merged, the preview will be destroyed.
|
||||||
|
comment-tag: preview-url
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -40,8 +40,7 @@ venv/
|
|||||||
ENV/
|
ENV/
|
||||||
env.bak/
|
env.bak/
|
||||||
venv.bak/
|
venv.bak/
|
||||||
env.d/development/*
|
env.d/development/*.local
|
||||||
!env.d/development/*.dist
|
|
||||||
env.d/terraform
|
env.d/terraform
|
||||||
|
|
||||||
# npm
|
# npm
|
||||||
@@ -76,3 +75,6 @@ db.sqlite3
|
|||||||
.vscode/
|
.vscode/
|
||||||
*.iml
|
*.iml
|
||||||
.devcontainer
|
.devcontainer
|
||||||
|
|
||||||
|
# Cursor rules
|
||||||
|
.cursorrules
|
||||||
|
|||||||
489
CHANGELOG.md
489
CHANGELOG.md
@@ -1,5 +1,3 @@
|
|||||||
# Changelog
|
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0),
|
||||||
@@ -8,6 +6,407 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- ✨ Add comments feature to the editor #1330
|
||||||
|
- ✨(backend) Comments on text editor #1330
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- ⚡️(sw) stop to cache external resources likes videos #1655
|
||||||
|
- 💥(frontend) upgrade to ui-kit v2
|
||||||
|
- ⚡️(frontend) improve perf on upload and table of contents #1662
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- ♿(frontend) improve accessibility:
|
||||||
|
- ♿(frontend) improve share modal button accessibility #1626
|
||||||
|
- ♿(frontend) improve screen reader support in DocShare modal #1628
|
||||||
|
- 🐛(frontend) fix toolbar not activated when reader #1640
|
||||||
|
- 🐛(frontend) preserve left panel width on window resize #1588
|
||||||
|
- 🐛(frontend) prevent duplicate as first character in title #1595
|
||||||
|
|
||||||
|
## [3.10.0] - 2025-11-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- ✨(export) enable ODT export for documents #1524
|
||||||
|
- ✨(frontend) improve mobile UX by showing subdocs count #1540
|
||||||
|
- ✅(e2e) add test to compare generated PDF against reference template #1648
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- ♻️(frontend) preserve @ character when esc is pressed after typing it #1512
|
||||||
|
- ♻️(frontend) make summary button fixed to remain visible during scroll #1581
|
||||||
|
- ♻️(frontend) pdf embed use full width #1526
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- ♿(frontend) improve accessibility:
|
||||||
|
- ♿(frontend) improve ARIA in doc grid and editor for a11y #1519
|
||||||
|
- ♿(frontend) improve accessibility and styling of summary table #1528
|
||||||
|
- ♿(frontend) add focus trap and enter key support to remove doc modal #1531
|
||||||
|
- 🐛(frontend) fix alignment of side menu #1597
|
||||||
|
- 🐛(frontend) fix fallback translations with Trans #1620
|
||||||
|
- 🐛(export) fix image overflow by limiting width to 600px during export #1525
|
||||||
|
- 🐛(export) fix table cell alignment issue in exported documents #1582
|
||||||
|
- 🐛(export) preserve image aspect ratio in PDF export #1622
|
||||||
|
- 🐛(export) Export fails when paste with style #1552
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- mitigate role escalation in the ask_for_access viewset #1580
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- 🔥(backend) remove api managing templates
|
||||||
|
|
||||||
|
## [3.9.0] - 2025-11-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- ✨(frontend) create skeleton component for DocEditor #1491
|
||||||
|
- ✨(frontend) add an EmojiPicker in the document tree and title #1381
|
||||||
|
- ✨(frontend) ajustable left panel #1456
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- ♻️(frontend) adapt custom blocks to new implementation #1375
|
||||||
|
- ♻️(backend) increase user short_name field length #1510
|
||||||
|
- 🚸(frontend) separate viewers from editors #1509
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🐛(frontend) fix duplicate document entries in grid #1479
|
||||||
|
- 🐛(backend) fix trashbin list #1520
|
||||||
|
- ♿(frontend) improve accessibility:
|
||||||
|
- ♿(frontend) remove empty alt on logo due to Axe a11y error #1516
|
||||||
|
- 🐛(backend) fix s3 version_id validation #1543
|
||||||
|
- 🐛(frontend) retry check media status after page reload #1555
|
||||||
|
- 🐛(frontend) fix Interlinking memory leak #1560
|
||||||
|
- 🐛(frontend) button new doc UI fix #1557
|
||||||
|
- 🐛(frontend) interlinking UI fix #1557
|
||||||
|
|
||||||
|
## [3.8.2] - 2025-10-17
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🐛(service-worker) fix sw registration and page reload logic #1500
|
||||||
|
|
||||||
|
## [3.8.1] - 2025-10-17
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- ⚡️(backend) improve trashbin endpoint performance #1495
|
||||||
|
- 🐛(backend) manage invitation partial update without email #1494
|
||||||
|
- ♿(frontend) improve accessibility:
|
||||||
|
- ♿ add missing aria-label to add sub-doc button for accessibility #1480
|
||||||
|
- ♿ add missing aria-label to more options button on sub-docs #1481
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- 🔥(backend) remove treebeard form for the document admin #1470
|
||||||
|
|
||||||
|
## [3.8.0] - 2025-10-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- ✨(frontend) add pdf block to the editor #1293
|
||||||
|
- ✨List and restore deleted docs #1450
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- ♻️(frontend) Refactor Auth component for improved redirection logic #1461
|
||||||
|
- ♻️(frontend) replace Arial font-family with token font #1411
|
||||||
|
- ♿(frontend) improve accessibility:
|
||||||
|
- ♿(frontend) enable enter key to open documentss #1354
|
||||||
|
- ♿(frontend) improve modal a11y: structure, labels, title #1349
|
||||||
|
- ♿improve NVDA navigation in DocShareModal #1396
|
||||||
|
- ♿ improve accessibility by adding landmark roles to layout #1394
|
||||||
|
- ♿ add document visible in list and openable via enter key #1365
|
||||||
|
- ♿ add pdf outline property to enable bookmarks display #1368
|
||||||
|
- ♿ hide decorative icons from assistive tech with aria-hidden #1404
|
||||||
|
- ♿ fix rgaa 1.9.1: convert to figure/figcaption structure #1426
|
||||||
|
- ♿ remove redundant aria-label to avoid over-accessibility #1420
|
||||||
|
- ♿ remove redundant aria-label on hidden icons and update tests #1432
|
||||||
|
- ♿ improve semantic structure and aria roles of leftpanel #1431
|
||||||
|
- ♿ add default background to left panel for better accessibility #1423
|
||||||
|
- ♿ restyle checked checkboxes: removing strikethrough #1439
|
||||||
|
- ♿ add h1 for SR on 40X pages and remove alt texts #1438
|
||||||
|
- ♿ update labels and shared document icon accessibility #1442
|
||||||
|
- 🍱(frontend) Fonts GDPR compliants #1453
|
||||||
|
- ♻️(service-worker) improve SW registration and update handling #1473
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🐛(backend) duplicate sub docs as root for reader users #1385
|
||||||
|
- ⚗️(service-worker) remove index from cache first strategy #1395
|
||||||
|
- 🐛(frontend) fix 404 page when reload 403 page #1402
|
||||||
|
- 🐛(frontend) fix legacy role computation #1376
|
||||||
|
- 🛂(frontend) block editing title when not allowed #1412
|
||||||
|
- 🐛(frontend) scroll back to top when navigate to a document #1406
|
||||||
|
- 🐛(frontend) fix export pdf emoji problem #1453
|
||||||
|
- 🐛(frontend) fix attachment download filename #1447
|
||||||
|
- 🐛(frontend) exclude h4-h6 headings from table of contents #1441
|
||||||
|
- 🔒(frontend) prevent readers from changing callout emoji #1449
|
||||||
|
- 🐛(frontend) fix overlapping placeholders in multi-column layout #1455
|
||||||
|
- 🐛(backend) filter invitation with case insensitive email #1457
|
||||||
|
- 🐛(frontend) reduce no access image size from 450 to 300 #1463
|
||||||
|
- 🐛(frontend) preserve interlink style on drag-and-drop in editor #1460
|
||||||
|
- ✨(frontend) load docs logo from public folder via url #1462
|
||||||
|
- 🔧(keycloak) Fix https required issue in dev mode #1286
|
||||||
|
|
||||||
|
## Removed
|
||||||
|
|
||||||
|
- 🔥(frontend) remove custom DividerBlock ##1375
|
||||||
|
|
||||||
|
## [3.7.0] - 2025-09-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- ✨(api) add API route to fetch document content #1206
|
||||||
|
- ✨(frontend) doc emojis improvements #1381
|
||||||
|
- add an EmojiPicker in the document tree and document title
|
||||||
|
- remove emoji buttons in menus
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 🔒️(backend) configure throttle on every viewsets #1343
|
||||||
|
- ⬆️ Bump eslint to V9 #1071
|
||||||
|
- ♿(frontend) improve accessibility:
|
||||||
|
- ♿fix major accessibility issues reported by wave and axe #1344
|
||||||
|
- ✨unify tab focus style for better visual consistency #1341
|
||||||
|
- ✨improve modal a11y: structure, labels, and title #1349
|
||||||
|
- ✨improve accessibility of cdoc content with correct aria tags #1271
|
||||||
|
- ✨unify tab focus style for better visual consistency #1341
|
||||||
|
- ♿hide decorative icons, label menus, avoid accessible name… #1362
|
||||||
|
- ♻️(tilt) use helm dev-backend chart
|
||||||
|
- 🩹(frontend) on main pages do not display leading emoji as page icon #1381
|
||||||
|
- 🩹(frontend) handle properly emojis in interlinking #1381
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- 🔥(frontend) remove multi column drop cursor #1370
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🐛(frontend) fix callout emoji list #1366
|
||||||
|
|
||||||
|
## [3.6.0] - 2025-09-04
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 👷(CI) add bundle size check job #1268
|
||||||
|
- ✨(frontend) use title first emoji as doc icon in tree #1289
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- ♻️(docs-app) Switch from Jest tests to Vitest #1269
|
||||||
|
- ♿(frontend) improve accessibility:
|
||||||
|
- 🌐(frontend) set html lang attribute dynamically #1248
|
||||||
|
- ♿(frontend) inject language attribute to pdf export #1235
|
||||||
|
- ♿(frontend) improve accessibility of search modal #1275
|
||||||
|
- ♿(frontend) add correct attributes to icons #1255
|
||||||
|
- 🎨(frontend) improve nav structure #1262
|
||||||
|
- ♿️(frontend) keyboard interaction with menu #1244
|
||||||
|
- ♿(frontend) improve header accessibility #1270
|
||||||
|
- ♿(frontend) improve accessibility for decorative images in editor #1282
|
||||||
|
- #1338
|
||||||
|
- #1281
|
||||||
|
- ♻️(backend) fallback to email identifier when no name #1298
|
||||||
|
- 🐛(backend) allow ASCII characters in user sub field #1295
|
||||||
|
- ⚡️(frontend) improve fallback width calculation #1333
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🐛(makefile) Windows compatibility fix for Docker volume mounting #1263
|
||||||
|
- 🐛(minio) fix user permission error with Minio and Windows #1263
|
||||||
|
- 🐛(frontend) fix export when quote block and inline code #1319
|
||||||
|
- 🐛(frontend) fix base64 font #1324
|
||||||
|
- 🐛(backend) allow creator to delete subpages #1297
|
||||||
|
- 🐛(frontend) fix dnd conflict with tree and Blocknote #1328
|
||||||
|
- 🐛(frontend) fix display bug on homepage #1332
|
||||||
|
- 🐛link role update #1287
|
||||||
|
|
||||||
|
## [3.5.0] - 2025-07-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- ✨(helm) Service Account support for K8s Resources in Helm Charts #780
|
||||||
|
- ✨(backend) allow masking documents from the list view #1172
|
||||||
|
- ✨(frontend) subdocs can manage link reach #1190
|
||||||
|
- ✨(frontend) add duplicate action to doc tree #1175
|
||||||
|
- ✨(frontend) Interlinking doc #904
|
||||||
|
- ✨(frontend) add multi columns support for editor #1219
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- ♻️(frontend) search on all docs if no children #1184
|
||||||
|
- ♻️(frontend) redirect to doc after duplicate #1175
|
||||||
|
- 🔧(project) change env.d system by using local files #1200
|
||||||
|
- ⚡️(frontend) improve tree stability #1207
|
||||||
|
- ⚡️(frontend) improve accessibility #1232
|
||||||
|
- 🛂(frontend) block drag n drop when not desktop #1239
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🐛(service-worker) Fix useOffline Maximum update depth exceeded #1196
|
||||||
|
- 🐛(frontend) fix empty left panel after deleting root doc #1197
|
||||||
|
- 🐛(helm) charts generate invalid YAML for collaboration API / WS #890
|
||||||
|
- 🐛(frontend) 401 redirection overridden #1214
|
||||||
|
- 🐛(frontend) include root parent in search #1243
|
||||||
|
|
||||||
|
## [3.4.2] - 2025-07-18
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- ⚡️(docker) Optimize Dockerfile to use apk with --no-cache #743
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🐛(backend) improve prompt to not use code blocks delimiter #1188
|
||||||
|
|
||||||
|
## [3.4.1] - 2025-07-15
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🌐(frontend) keep simple tag during export #1154
|
||||||
|
- 🐛(back) manage can-edit endpoint without created room
|
||||||
|
in the ws #1152
|
||||||
|
- 🐛(frontend) fix action buttons not clickable #1162
|
||||||
|
- 🐛(frontend) fix crash share modal on grid options #1174
|
||||||
|
- 🐛(frontend) fix unfold subdocs not clickable at the bottom #1179
|
||||||
|
|
||||||
|
## [3.4.0] - 2025-07-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- ✨(frontend) multi-pages #701
|
||||||
|
- ✨(frontend) Duplicate a doc #1078
|
||||||
|
- ✨Ask for access #1081
|
||||||
|
- ✨(frontend) add customization for translations #857
|
||||||
|
- ✨(backend) add ancestors links definitions to document abilities #846
|
||||||
|
- ✨(backend) include ancestors accesses on document accesses list view #846
|
||||||
|
- ✨(backend) add ancestors links reach and role to document API #846
|
||||||
|
- 📝(project) add troubleshoot doc #1066
|
||||||
|
- 📝(project) add system-requirement doc #1066
|
||||||
|
- 🔧(frontend) configure x-frame-options to DENY in nginx conf #1084
|
||||||
|
- ✨(backend) allow to disable checking unsafe mimetype on
|
||||||
|
attachment upload #1099
|
||||||
|
- ✨(doc) add documentation to install with compose #855
|
||||||
|
- ✨ Give priority to users connected to collaboration server
|
||||||
|
(aka no websocket feature) #1093
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- ♻️(backend) stop requiring owner for non-root documents #846
|
||||||
|
- ♻️(backend) simplify roles by ranking them and return only the max role #846
|
||||||
|
- 📌(yjs) stop pinning node to minor version on yjs docker image #1005
|
||||||
|
- 🧑💻(docker) add .next to .dockerignore #1055
|
||||||
|
- 🧑💻(docker) handle frontend development images with docker compose #1033
|
||||||
|
- 🧑💻(docker) add y-provider config to development environment #1057
|
||||||
|
- ⚡️(frontend) optimize document fetch error handling #1089
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🐛(backend) fix link definition select options linked to ancestors #846
|
||||||
|
- 🐛(frontend) table of content disappearing #982
|
||||||
|
- 🐛(frontend) fix multiple EmojiPicker #1012
|
||||||
|
- 🐛(frontend) fix meta title #1017
|
||||||
|
- 🔧(git) set LF line endings for all text files #1032
|
||||||
|
- 📝(docs) minor fixes to docs/env.md
|
||||||
|
- ✨support `_FILE` environment variables for secrets #912
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- 🔥(frontend) remove Beta from logo #1095
|
||||||
|
|
||||||
|
## [3.3.0] - 2025-05-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- ✨(backend) add endpoint checking media status #984
|
||||||
|
- ✨(backend) allow setting session cookie age via env var #977
|
||||||
|
- ✨(backend) allow theme customnization using a configuration file #948
|
||||||
|
- ✨(frontend) Add a custom callout block to the editor #892
|
||||||
|
- 🚩(frontend) version MIT only #911
|
||||||
|
- ✨(backend) integrate maleware_detection from django-lasuite #936
|
||||||
|
- 🏗️(frontend) Footer configurable #959
|
||||||
|
- 🩺(CI) add lint spell mistakes #954
|
||||||
|
- ✨(frontend) create generic theme #792
|
||||||
|
- 🛂(frontend) block edition to not connected users #945
|
||||||
|
- 🚸(frontend) Let loader during upload analyze #984
|
||||||
|
- 🚩(frontend) feature flag on blocking edition #997
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 📝(frontend) Update documentation #949
|
||||||
|
- ✅(frontend) Improve tests coverage #949
|
||||||
|
- ⬆️(docker) upgrade backend image to python 3.13 #973
|
||||||
|
- ⬆️(docker) upgrade node images to alpine 3.21 #973
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🐛(y-provider) increase JSON size limits for transcription conversion #989
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- 🔥(back) remove footer endpoint #948
|
||||||
|
|
||||||
|
## [3.2.1] - 2025-05-06
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- 🐛(frontend) fix list copy paste #943
|
||||||
|
- 📝(doc) update contributing policy (commit signatures are now mandatory) #895
|
||||||
|
|
||||||
|
## [3.2.0] - 2025-05-05
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
- 🚸(backend) make document search on title accent-insensitive #874
|
||||||
|
- 🚩 add homepage feature flag #861
|
||||||
|
- 📝(doc) update contributing policy (commit signatures are now mandatory) #895
|
||||||
|
- ✨(settings) Allow configuring PKCE for the SSO #886
|
||||||
|
- 🌐(i18n) activate chinese and spanish languages #884
|
||||||
|
- 🔧(backend) allow overwriting the data directory #893
|
||||||
|
- ➕(backend) add `django-lasuite` dependency #839
|
||||||
|
- ✨(frontend) advanced table features #908
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
- ⚡️(frontend) reduce unblocking time for config #867
|
||||||
|
- ♻️(frontend) bind UI with ability access #900
|
||||||
|
- ♻️(frontend) use built-in Quote block #908
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- 🐛(nginx) fix 404 when accessing a doc #866
|
||||||
|
- 🔒️(drf) disable browsable HTML API renderer #919
|
||||||
|
- 🔒(frontend) enhance file download security #889
|
||||||
|
- 🐛(backend) race condition create doc #633
|
||||||
|
- 🐛(frontend) fix breaklines in custom blocks #908
|
||||||
|
|
||||||
|
## [3.1.0] - 2025-04-07
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
- 🚩(backend) add feature flag for the footer #841
|
||||||
|
- 🔧(backend) add view to manage footer json #841
|
||||||
|
- ✨(frontend) add custom css style #771
|
||||||
|
- 🚩(frontend) conditionally render AI button only when feature is enabled #814
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
- 🚨(frontend) block button when creating doc #749
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- 🐛(back) validate document content in serializer #822
|
||||||
|
- 🐛(frontend) fix selection click past end of content #840
|
||||||
|
|
||||||
|
## [3.0.0] - 2025-03-28
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
|
|
||||||
- 📄(legal) Require contributors to sign a DCO #779
|
- 📄(legal) Require contributors to sign a DCO #779
|
||||||
@@ -15,6 +414,12 @@ and this project adheres to
|
|||||||
## Changed
|
## Changed
|
||||||
|
|
||||||
- ♻️(frontend) Integrate UI kit #783
|
- ♻️(frontend) Integrate UI kit #783
|
||||||
|
- 🏗️(y-provider) manage auth in y-provider app #804
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- 🐛(backend) compute ancestor_links in get_abilities if needed #725
|
||||||
|
- 🔒️(back) restrict access to document accesses #801
|
||||||
|
|
||||||
## [2.6.0] - 2025-03-21
|
## [2.6.0] - 2025-03-21
|
||||||
|
|
||||||
@@ -34,7 +439,6 @@ and this project adheres to
|
|||||||
- 🔒️(back) throttle user list endpoint #636
|
- 🔒️(back) throttle user list endpoint #636
|
||||||
- 🔒️(back) remove pagination and limit to 5 for user list endpoint #636
|
- 🔒️(back) remove pagination and limit to 5 for user list endpoint #636
|
||||||
|
|
||||||
|
|
||||||
## [2.5.0] - 2025-03-18
|
## [2.5.0] - 2025-03-18
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
@@ -57,15 +461,14 @@ and this project adheres to
|
|||||||
## Fixed
|
## Fixed
|
||||||
|
|
||||||
- 🐛(frontend) SVG export #706
|
- 🐛(frontend) SVG export #706
|
||||||
- 🐛(frontend) remove scroll listener table content #688
|
- 🐛(frontend) remove scroll listener table content #688
|
||||||
- 🔒️(back) restrict access to favorite_list endpoint #690
|
- 🔒️(back) restrict access to favorite_list endpoint #690
|
||||||
- 🐛(backend) refactor to fix filtering on children
|
- 🐛(backend) refactor to fix filtering on children
|
||||||
and descendants views #695
|
and descendants views #695
|
||||||
- 🐛(action) fix notify-argocd workflow #713
|
- 🐛(action) fix notify-argocd workflow #713
|
||||||
- 🚨(helm) fix helmfile lint #736
|
- 🚨(helm) fix helmfile lint #736
|
||||||
- 🚚(frontend) redirect to 401 page when 401 error #759
|
- 🚚(frontend) redirect to 401 page when 401 error #759
|
||||||
|
|
||||||
|
|
||||||
## [2.4.0] - 2025-03-06
|
## [2.4.0] - 2025-03-06
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
@@ -80,7 +483,6 @@ and this project adheres to
|
|||||||
|
|
||||||
- 🐛(frontend) fix collaboration error #684
|
- 🐛(frontend) fix collaboration error #684
|
||||||
|
|
||||||
|
|
||||||
## [2.3.0] - 2025-03-03
|
## [2.3.0] - 2025-03-03
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
@@ -108,7 +510,6 @@ and this project adheres to
|
|||||||
- ♻️(frontend) improve table pdf rendering
|
- ♻️(frontend) improve table pdf rendering
|
||||||
- 🐛(email) invitation emails in receivers language
|
- 🐛(email) invitation emails in receivers language
|
||||||
|
|
||||||
|
|
||||||
## [2.2.0] - 2025-02-10
|
## [2.2.0] - 2025-02-10
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
@@ -132,6 +533,8 @@ and this project adheres to
|
|||||||
|
|
||||||
## Added
|
## Added
|
||||||
|
|
||||||
|
- ✨(backend) add duplicate action to the document API endpoint
|
||||||
|
- ⚗️(backend) add util to extract text from base64 yjs document
|
||||||
- ✨(backend) add soft delete and restore API endpoints to documents #516
|
- ✨(backend) add soft delete and restore API endpoints to documents #516
|
||||||
- ✨(backend) allow organizing documents in a tree structure #516
|
- ✨(backend) allow organizing documents in a tree structure #516
|
||||||
- ✨(backend) add "excerpt" field to document list serializer #516
|
- ✨(backend) add "excerpt" field to document list serializer #516
|
||||||
@@ -477,7 +880,7 @@ and this project adheres to
|
|||||||
- ⚡️(e2e) unique login between tests (#80)
|
- ⚡️(e2e) unique login between tests (#80)
|
||||||
- ⚡️(CI) improve e2e job (#86)
|
- ⚡️(CI) improve e2e job (#86)
|
||||||
- ♻️(frontend) improve the error and message info ui (#93)
|
- ♻️(frontend) improve the error and message info ui (#93)
|
||||||
- ✏️(frontend) change all occurences of pad to doc (#99)
|
- ✏️(frontend) change all occurrences of pad to doc (#99)
|
||||||
|
|
||||||
## Fixed
|
## Fixed
|
||||||
|
|
||||||
@@ -495,28 +898,44 @@ and this project adheres to
|
|||||||
- ✨(frontend) Coming Soon page (#67)
|
- ✨(frontend) Coming Soon page (#67)
|
||||||
- 🚀 Impress, project to manage your documents easily and collaboratively.
|
- 🚀 Impress, project to manage your documents easily and collaboratively.
|
||||||
|
|
||||||
[unreleased]: https://github.com/numerique-gouv/impress/compare/v2.6.0...main
|
[unreleased]: https://github.com/suitenumerique/docs/compare/v3.10.0...main
|
||||||
[v2.5.0]: https://github.com/numerique-gouv/impress/releases/v2.6.0
|
[v3.10.0]: https://github.com/suitenumerique/docs/releases/v3.10.0
|
||||||
[v2.5.0]: https://github.com/numerique-gouv/impress/releases/v2.5.0
|
[v3.9.0]: https://github.com/suitenumerique/docs/releases/v3.9.0
|
||||||
[v2.4.0]: https://github.com/numerique-gouv/impress/releases/v2.4.0
|
[v3.8.2]: https://github.com/suitenumerique/docs/releases/v3.8.2
|
||||||
[v2.3.0]: https://github.com/numerique-gouv/impress/releases/v2.3.0
|
[v3.8.1]: https://github.com/suitenumerique/docs/releases/v3.8.1
|
||||||
[v2.2.0]: https://github.com/numerique-gouv/impress/releases/v2.2.0
|
[v3.8.0]: https://github.com/suitenumerique/docs/releases/v3.8.0
|
||||||
[v2.1.0]: https://github.com/numerique-gouv/impress/releases/v2.1.0
|
[v3.7.0]: https://github.com/suitenumerique/docs/releases/v3.7.0
|
||||||
[v2.0.1]: https://github.com/numerique-gouv/impress/releases/v2.0.1
|
[v3.6.0]: https://github.com/suitenumerique/docs/releases/v3.6.0
|
||||||
[v2.0.0]: https://github.com/numerique-gouv/impress/releases/v2.0.0
|
[v3.5.0]: https://github.com/suitenumerique/docs/releases/v3.5.0
|
||||||
[v1.10.0]: https://github.com/numerique-gouv/impress/releases/v1.10.0
|
[v3.4.2]: https://github.com/suitenumerique/docs/releases/v3.4.2
|
||||||
[v1.9.0]: https://github.com/numerique-gouv/impress/releases/v1.9.0
|
[v3.4.1]: https://github.com/suitenumerique/docs/releases/v3.4.1
|
||||||
[v1.8.2]: https://github.com/numerique-gouv/impress/releases/v1.8.2
|
[v3.4.0]: https://github.com/suitenumerique/docs/releases/v3.4.0
|
||||||
[v1.8.1]: https://github.com/numerique-gouv/impress/releases/v1.8.1
|
[v3.3.0]: https://github.com/suitenumerique/docs/releases/v3.3.0
|
||||||
[v1.8.0]: https://github.com/numerique-gouv/impress/releases/v1.8.0
|
[v3.2.1]: https://github.com/suitenumerique/docs/releases/v3.2.1
|
||||||
[v1.7.0]: https://github.com/numerique-gouv/impress/releases/v1.7.0
|
[v3.2.0]: https://github.com/suitenumerique/docs/releases/v3.2.0
|
||||||
[v1.6.0]: https://github.com/numerique-gouv/impress/releases/v1.6.0
|
[v3.1.0]: https://github.com/suitenumerique/docs/releases/v3.1.0
|
||||||
[1.5.1]: https://github.com/numerique-gouv/impress/releases/v1.5.1
|
[v3.0.0]: https://github.com/suitenumerique/docs/releases/v3.0.0
|
||||||
[1.5.0]: https://github.com/numerique-gouv/impress/releases/v1.5.0
|
[v2.6.0]: https://github.com/suitenumerique/docs/releases/v2.6.0
|
||||||
[1.4.0]: https://github.com/numerique-gouv/impress/releases/v1.4.0
|
[v2.5.0]: https://github.com/suitenumerique/docs/releases/v2.5.0
|
||||||
[1.3.0]: https://github.com/numerique-gouv/impress/releases/v1.3.0
|
[v2.4.0]: https://github.com/suitenumerique/docs/releases/v2.4.0
|
||||||
[1.2.1]: https://github.com/numerique-gouv/impress/releases/v1.2.1
|
[v2.3.0]: https://github.com/suitenumerique/docs/releases/v2.3.0
|
||||||
[1.2.0]: https://github.com/numerique-gouv/impress/releases/v1.2.0
|
[v2.2.0]: https://github.com/suitenumerique/docs/releases/v2.2.0
|
||||||
[1.1.0]: https://github.com/numerique-gouv/impress/releases/v1.1.0
|
[v2.1.0]: https://github.com/suitenumerique/docs/releases/v2.1.0
|
||||||
[1.0.0]: https://github.com/numerique-gouv/impress/releases/v1.0.0
|
[v2.0.1]: https://github.com/suitenumerique/docs/releases/v2.0.1
|
||||||
[0.1.0]: https://github.com/numerique-gouv/impress/releases/v0.1.0
|
[v2.0.0]: https://github.com/suitenumerique/docs/releases/v2.0.0
|
||||||
|
[v1.10.0]: https://github.com/suitenumerique/docs/releases/v1.10.0
|
||||||
|
[v1.9.0]: https://github.com/suitenumerique/docs/releases/v1.9.0
|
||||||
|
[v1.8.2]: https://github.com/suitenumerique/docs/releases/v1.8.2
|
||||||
|
[v1.8.1]: https://github.com/suitenumerique/docs/releases/v1.8.1
|
||||||
|
[v1.8.0]: https://github.com/suitenumerique/docs/releases/v1.8.0
|
||||||
|
[v1.7.0]: https://github.com/suitenumerique/docs/releases/v1.7.0
|
||||||
|
[v1.6.0]: https://github.com/suitenumerique/docs/releases/v1.6.0
|
||||||
|
[1.5.1]: https://github.com/suitenumerique/docs/releases/v1.5.1
|
||||||
|
[1.5.0]: https://github.com/suitenumerique/docs/releases/v1.5.0
|
||||||
|
[1.4.0]: https://github.com/suitenumerique/docs/releases/v1.4.0
|
||||||
|
[1.3.0]: https://github.com/suitenumerique/docs/releases/v1.3.0
|
||||||
|
[1.2.1]: https://github.com/suitenumerique/docs/releases/v1.2.1
|
||||||
|
[1.2.0]: https://github.com/suitenumerique/docs/releases/v1.2.0
|
||||||
|
[1.1.0]: https://github.com/suitenumerique/docs/releases/v1.1.0
|
||||||
|
[1.0.0]: https://github.com/suitenumerique/docs/releases/v1.0.0
|
||||||
|
[0.1.0]: https://github.com/suitenumerique/docs/releases/v0.1.0
|
||||||
|
|||||||
@@ -42,34 +42,38 @@ Examples of unacceptable behavior include:
|
|||||||
|
|
||||||
## Enforcement Guidelines
|
## Enforcement Guidelines
|
||||||
|
|
||||||
- Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this
|
- Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of the following Code of Conduct
|
||||||
|
|
||||||
## Code of Conduct:
|
## Code of Conduct:
|
||||||
|
|
||||||
1. Correction
|
### 1. Correction
|
||||||
|
|
||||||
Community Impact: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
|
Community Impact: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
|
||||||
|
|
||||||
Consequence: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
|
Consequence: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
|
||||||
2. Warning
|
|
||||||
|
### 2. Warning
|
||||||
|
|
||||||
Community Impact: A violation through a single incident or series of actions.
|
Community Impact: A violation through a single incident or series of actions.
|
||||||
|
|
||||||
Consequence: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
|
Consequence: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
|
||||||
3. Temporary Ban
|
|
||||||
|
### 3. Temporary Ban
|
||||||
|
|
||||||
Community Impact: A serious violation of community standards, including sustained inappropriate behavior.
|
Community Impact: A serious violation of community standards, including sustained inappropriate behavior.
|
||||||
|
|
||||||
Consequence: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
|
Consequence: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
|
||||||
4. Permanent Ban
|
|
||||||
|
### 4. Permanent Ban
|
||||||
|
|
||||||
Community Impact: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
|
Community Impact: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
|
||||||
|
|
||||||
Consequence: A permanent ban from any sort of public interaction within the community.
|
Consequence: A permanent ban from any sort of public interaction within the community.
|
||||||
Attribution
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
This Code of Conduct is adapted from the Contributor Covenant, version 2.1, available at https://www.contributor-covenant.org/version/2/1/code_of_conduct.html.
|
This Code of Conduct is adapted from the Contributor Covenant, version 2.1, available at https://www.contributor-covenant.org/version/2/1/code_of_conduct.html.
|
||||||
|
|
||||||
Community Impact Guidelines were inspired by Mozilla's code of conduct enforcement ladder.
|
Community Impact Guidelines were inspired by Mozilla's [code of conduct enforcement ladder](https://github.com/mozilla/inclusion/blob/master/code-of-conduct-enforcement/consequence-ladder.md).
|
||||||
|
|
||||||
For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations.
|
For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations.
|
||||||
@@ -2,16 +2,16 @@
|
|||||||
|
|
||||||
Thank you for taking the time to contribute! Please follow these guidelines to ensure a smooth and productive workflow. 🚀🚀🚀
|
Thank you for taking the time to contribute! Please follow these guidelines to ensure a smooth and productive workflow. 🚀🚀🚀
|
||||||
|
|
||||||
To get started with the project, please refer to the [README.md](https://github.com/suitenumerique/docs/blob/main/README.md) for detailed instructions.
|
To get started with the project, please refer to the [README.md](https://github.com/suitenumerique/docs/blob/main/README.md) for detailed instructions on how to run Docs locally.
|
||||||
|
|
||||||
Contributors are required to sign off their commits with `git commit --sign-off`: this confirms that they have read and accepted the [Developer's Certificate of Origin 1.1](https://developercertificate.org/).
|
Contributors are required to sign off their commits with `git commit --signoff`: this confirms that they have read and accepted the [Developer's Certificate of Origin 1.1](https://developercertificate.org/). For security reasons we also require [signing your commits with your SSH or GPG key](https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification) with `git commit -S`.
|
||||||
|
|
||||||
Please also check out our [dev handbook](https://suitenumerique.gitbook.io/handbook) to learn our best practices.
|
Please also check out our [dev handbook](https://suitenumerique.gitbook.io/handbook) to learn our best practices.
|
||||||
|
|
||||||
## Help us with translations
|
## Help us with translations
|
||||||
|
|
||||||
You can help us with translations on [Crowdin](https://crowdin.com/project/lasuite-docs).
|
You can help us with translations on [Crowdin](https://crowdin.com/project/lasuite-docs).
|
||||||
Your language is not there? Request it on our Crowdin page 😊.
|
Your language is not there? Request it on our Crowdin page 😊 or ping us on [Matrix](https://matrix.to/#/#docs-official:matrix.org) and let us know if you can help with translations and/or proofreading.
|
||||||
|
|
||||||
## Creating an Issue
|
## Creating an Issue
|
||||||
|
|
||||||
@@ -35,10 +35,14 @@ All commit messages must adhere to the following format:
|
|||||||
|
|
||||||
`<gitmoji>(type) title description`
|
`<gitmoji>(type) title description`
|
||||||
|
|
||||||
* <**gitmoji**>: Use a gitmoji to represent the purpose of the commit. For example, ✨ for adding a new feature or 🔥 for removing something, see the list here: <https://gitmoji.dev/>.
|
* <**gitmoji**>: Use a gitmoji to represent the purpose of the commit. For example, ✨ for adding a new feature or 🔥 for removing something, see the list [here](https://gitmoji.dev/).
|
||||||
* **(type)**: Describe the type of change. Common types include `backend`, `frontend`, `CI`, `docker` etc...
|
* **(type)**: Describe the type of change. Common types include `backend`, `frontend`, `CI`, `docker` etc...
|
||||||
* **title**: A short, descriptive title for the change.
|
* **title**: A short, descriptive title for the change (*)
|
||||||
* **description**: Include additional details about what was changed and why.
|
* **blank line after the commit title
|
||||||
|
* **description**: Include additional details on why you made the changes (**).
|
||||||
|
|
||||||
|
(*) ⚠️ **Make sure you add no space between the emoji and the (type) but add a space after the closing parenthesis of the type and use no caps!**
|
||||||
|
(**) ⚠️ **Commit description message is mandatory and shouldn't be too long**
|
||||||
|
|
||||||
### Example Commit Message
|
### Example Commit Message
|
||||||
|
|
||||||
@@ -66,7 +70,9 @@ Please add a line to the changelog describing your development. The changelog en
|
|||||||
It is nice to add information about the purpose of the pull request to help reviewers understand the context and intent of the changes. If you can, add some pictures or a small video to show the changes.
|
It is nice to add information about the purpose of the pull request to help reviewers understand the context and intent of the changes. If you can, add some pictures or a small video to show the changes.
|
||||||
|
|
||||||
### Don't forget to:
|
### Don't forget to:
|
||||||
- check your commits
|
- signoff your commits
|
||||||
|
- sign your commits with your key (SSH, GPG etc.)
|
||||||
|
- check your commits (see warnings above)
|
||||||
- check the linting: `make lint && make frontend-lint`
|
- check the linting: `make lint && make frontend-lint`
|
||||||
- check the tests: `make test`
|
- check the tests: `make test`
|
||||||
- add a changelog entry
|
- add a changelog entry
|
||||||
@@ -86,3 +92,11 @@ Make sure that all new features or fixes have corresponding tests. Run the test
|
|||||||
If you need any help while contributing, feel free to open a discussion or ask for guidance in the issue tracker. We are more than happy to assist!
|
If you need any help while contributing, feel free to open a discussion or ask for guidance in the issue tracker. We are more than happy to assist!
|
||||||
|
|
||||||
Thank you for your contributions! 👍
|
Thank you for your contributions! 👍
|
||||||
|
|
||||||
|
## Contribute to BlockNote
|
||||||
|
We use [BlockNote](https://www.blocknotejs.org/) for the text editing features of Docs.
|
||||||
|
If you find and issue with the editor you can [report it](https://github.com/TypeCellOS/BlockNote/issues) directly on their repository.
|
||||||
|
|
||||||
|
Please consider contributing to BlockNotejs, as a library, it's useful to many projects not just Docs.
|
||||||
|
|
||||||
|
The project is licended with Mozilla Public License Version 2.0 but be aware that [XL packages](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-docx-exporter/LICENSE) are dual licenced with GNU AFFERO GENERAL PUBLIC LICENCE Version 3 and proprietary licence if you are [sponsor](https://www.blocknotejs.org/pricing).
|
||||||
|
|||||||
31
Dockerfile
31
Dockerfile
@@ -1,20 +1,26 @@
|
|||||||
# Django impress
|
# Django impress
|
||||||
|
|
||||||
# ---- base image to inherit from ----
|
# ---- base image to inherit from ----
|
||||||
FROM python:3.12.6-alpine3.20 AS base
|
FROM python:3.13.3-alpine AS base
|
||||||
|
|
||||||
# Upgrade pip to its latest release to speed up dependencies installation
|
# Upgrade pip to its latest release to speed up dependencies installation
|
||||||
RUN python -m pip install --upgrade pip setuptools
|
RUN python -m pip install --upgrade pip setuptools
|
||||||
|
|
||||||
# Upgrade system packages to install security updates
|
# Upgrade system packages to install security updates
|
||||||
RUN apk update && \
|
RUN apk update && apk upgrade --no-cache
|
||||||
apk upgrade
|
|
||||||
|
|
||||||
# ---- Back-end builder image ----
|
# ---- Back-end builder image ----
|
||||||
FROM base AS back-builder
|
FROM base AS back-builder
|
||||||
|
|
||||||
WORKDIR /builder
|
WORKDIR /builder
|
||||||
|
|
||||||
|
# Install Rust and Cargo using Alpine's package manager
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
build-base \
|
||||||
|
libffi-dev \
|
||||||
|
rust \
|
||||||
|
cargo
|
||||||
|
|
||||||
# Copy required python dependencies
|
# Copy required python dependencies
|
||||||
COPY ./src/backend /builder
|
COPY ./src/backend /builder
|
||||||
|
|
||||||
@@ -23,7 +29,7 @@ RUN mkdir /install && \
|
|||||||
|
|
||||||
|
|
||||||
# ---- mails ----
|
# ---- mails ----
|
||||||
FROM node:20 AS mail-builder
|
FROM node:24 AS mail-builder
|
||||||
|
|
||||||
COPY ./src/mail /mail/app
|
COPY ./src/mail /mail/app
|
||||||
|
|
||||||
@@ -38,7 +44,7 @@ FROM base AS link-collector
|
|||||||
ARG IMPRESS_STATIC_ROOT=/data/static
|
ARG IMPRESS_STATIC_ROOT=/data/static
|
||||||
|
|
||||||
# Install pango & rdfind
|
# Install pango & rdfind
|
||||||
RUN apk add \
|
RUN apk add --no-cache \
|
||||||
pango \
|
pango \
|
||||||
rdfind
|
rdfind
|
||||||
|
|
||||||
@@ -64,7 +70,7 @@ FROM base AS core
|
|||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
# Install required system libs
|
# Install required system libs
|
||||||
RUN apk add \
|
RUN apk add --no-cache \
|
||||||
cairo \
|
cairo \
|
||||||
file \
|
file \
|
||||||
font-noto \
|
font-noto \
|
||||||
@@ -88,6 +94,14 @@ RUN chmod g=u /etc/passwd
|
|||||||
# Copy installed python dependencies
|
# Copy installed python dependencies
|
||||||
COPY --from=back-builder /install /usr/local
|
COPY --from=back-builder /install /usr/local
|
||||||
|
|
||||||
|
# Link certifi certificate from a static path /cert/cacert.pem to avoid issues
|
||||||
|
# when python is upgraded and the path to the certificate changes.
|
||||||
|
# The space between print and the ( is intended otherwise the git lint is failing
|
||||||
|
RUN mkdir /cert && \
|
||||||
|
path=`python -c 'import certifi;print (certifi.where())'` && \
|
||||||
|
mv $path /cert/ && \
|
||||||
|
ln -s /cert/cacert.pem $path
|
||||||
|
|
||||||
# Copy impress application (see .dockerignore)
|
# Copy impress application (see .dockerignore)
|
||||||
COPY ./src/backend /app/
|
COPY ./src/backend /app/
|
||||||
|
|
||||||
@@ -110,7 +124,7 @@ FROM core AS backend-development
|
|||||||
USER root:root
|
USER root:root
|
||||||
|
|
||||||
# Install psql
|
# Install psql
|
||||||
RUN apk add postgresql-client
|
RUN apk add --no-cache postgresql-client
|
||||||
|
|
||||||
# Uninstall impress and re-install it in editable mode along with development
|
# Uninstall impress and re-install it in editable mode along with development
|
||||||
# dependencies
|
# dependencies
|
||||||
@@ -132,6 +146,9 @@ CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
|||||||
# ---- Production image ----
|
# ---- Production image ----
|
||||||
FROM core AS backend-production
|
FROM core AS backend-production
|
||||||
|
|
||||||
|
# Remove apk cache, we don't need it anymore
|
||||||
|
RUN rm -rf /var/cache/apk/*
|
||||||
|
|
||||||
ARG IMPRESS_STATIC_ROOT=/data/static
|
ARG IMPRESS_STATIC_ROOT=/data/static
|
||||||
|
|
||||||
# Gunicorn
|
# Gunicorn
|
||||||
|
|||||||
173
Makefile
173
Makefile
@@ -35,10 +35,15 @@ DB_PORT = 5432
|
|||||||
|
|
||||||
# -- Docker
|
# -- Docker
|
||||||
# Get the current user ID to use for docker run and docker exec commands
|
# Get the current user ID to use for docker run and docker exec commands
|
||||||
DOCKER_UID = $(shell id -u)
|
ifeq ($(OS),Windows_NT)
|
||||||
DOCKER_GID = $(shell id -g)
|
DOCKER_USER := 0:0 # run containers as root on Windows
|
||||||
DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID)
|
else
|
||||||
|
DOCKER_UID := $(shell id -u)
|
||||||
|
DOCKER_GID := $(shell id -g)
|
||||||
|
DOCKER_USER := $(DOCKER_UID):$(DOCKER_GID)
|
||||||
|
endif
|
||||||
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
|
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
|
||||||
|
COMPOSE_E2E = DOCKER_USER=$(DOCKER_USER) docker compose -f compose.yml -f compose-e2e.yml
|
||||||
COMPOSE_EXEC = $(COMPOSE) exec
|
COMPOSE_EXEC = $(COMPOSE) exec
|
||||||
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
|
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
|
||||||
COMPOSE_RUN = $(COMPOSE) run --rm
|
COMPOSE_RUN = $(COMPOSE) run --rm
|
||||||
@@ -47,7 +52,7 @@ COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
|
|||||||
|
|
||||||
# -- Backend
|
# -- Backend
|
||||||
MANAGE = $(COMPOSE_RUN_APP) python manage.py
|
MANAGE = $(COMPOSE_RUN_APP) python manage.py
|
||||||
MAIL_YARN = $(COMPOSE_RUN) -w /app/src/mail node yarn
|
MAIL_YARN = $(COMPOSE_RUN) -w //app/src/mail node yarn
|
||||||
|
|
||||||
# -- Frontend
|
# -- Frontend
|
||||||
PATH_FRONT = ./src/frontend
|
PATH_FRONT = ./src/frontend
|
||||||
@@ -66,30 +71,111 @@ data/static:
|
|||||||
|
|
||||||
# -- Project
|
# -- Project
|
||||||
|
|
||||||
create-env-files: ## Copy the dist env files to env files
|
create-env-local-files: ## create env.local files in env.d/development
|
||||||
create-env-files: \
|
create-env-local-files:
|
||||||
env.d/development/common \
|
@touch env.d/development/crowdin.local
|
||||||
env.d/development/crowdin \
|
@touch env.d/development/common.local
|
||||||
env.d/development/postgresql \
|
@touch env.d/development/postgresql.local
|
||||||
env.d/development/kc_postgresql
|
@touch env.d/development/kc_postgresql.local
|
||||||
.PHONY: create-env-files
|
.PHONY: create-env-local-files
|
||||||
|
|
||||||
bootstrap: ## Prepare Docker images for the project
|
pre-bootstrap: \
|
||||||
bootstrap: \
|
|
||||||
data/media \
|
data/media \
|
||||||
data/static \
|
data/static \
|
||||||
create-env-files \
|
create-env-local-files
|
||||||
build \
|
.PHONY: pre-bootstrap
|
||||||
|
|
||||||
|
post-bootstrap: \
|
||||||
migrate \
|
migrate \
|
||||||
demo \
|
demo \
|
||||||
back-i18n-compile \
|
back-i18n-compile \
|
||||||
mails-install \
|
mails-install \
|
||||||
mails-build \
|
mails-build
|
||||||
run
|
.PHONY: post-bootstrap
|
||||||
|
|
||||||
|
pre-beautiful-bootstrap: ## Display a welcome message before bootstrap
|
||||||
|
ifeq ($(OS),Windows_NT)
|
||||||
|
@echo ""
|
||||||
|
@echo "================================================================================"
|
||||||
|
@echo ""
|
||||||
|
@echo " Welcome to Docs - Collaborative Text Editing from La Suite!"
|
||||||
|
@echo ""
|
||||||
|
@echo " This will set up your development environment with:"
|
||||||
|
@echo " - Docker containers for all services"
|
||||||
|
@echo " - Database migrations and static files"
|
||||||
|
@echo " - Frontend dependencies and build"
|
||||||
|
@echo " - Environment configuration files"
|
||||||
|
@echo ""
|
||||||
|
@echo " Services will be available at:"
|
||||||
|
@echo " - Frontend: http://localhost:3000"
|
||||||
|
@echo " - API: http://localhost:8071"
|
||||||
|
@echo " - Admin: http://localhost:8071/admin"
|
||||||
|
@echo ""
|
||||||
|
@echo "================================================================================"
|
||||||
|
@echo ""
|
||||||
|
@echo "Starting bootstrap process..."
|
||||||
|
else
|
||||||
|
@echo "$(BOLD)"
|
||||||
|
@echo "╔══════════════════════════════════════════════════════════════════════════════╗"
|
||||||
|
@echo "║ ║"
|
||||||
|
@echo "║ 🚀 Welcome to Docs - Collaborative Text Editing from La Suite ! 🚀 ║"
|
||||||
|
@echo "║ ║"
|
||||||
|
@echo "║ This will set up your development environment with : ║"
|
||||||
|
@echo "║ • Docker containers for all services ║"
|
||||||
|
@echo "║ • Database migrations and static files ║"
|
||||||
|
@echo "║ • Frontend dependencies and build ║"
|
||||||
|
@echo "║ • Environment configuration files ║"
|
||||||
|
@echo "║ ║"
|
||||||
|
@echo "║ Services will be available at: ║"
|
||||||
|
@echo "║ • Frontend: http://localhost:3000 ║"
|
||||||
|
@echo "║ • API: http://localhost:8071 ║"
|
||||||
|
@echo "║ • Admin: http://localhost:8071/admin ║"
|
||||||
|
@echo "║ ║"
|
||||||
|
@echo "╚══════════════════════════════════════════════════════════════════════════════╝"
|
||||||
|
@echo "$(RESET)"
|
||||||
|
@echo "$(GREEN)Starting bootstrap process...$(RESET)"
|
||||||
|
endif
|
||||||
|
@echo ""
|
||||||
|
.PHONY: pre-beautiful-bootstrap
|
||||||
|
|
||||||
|
post-beautiful-bootstrap: ## Display a success message after bootstrap
|
||||||
|
@echo ""
|
||||||
|
ifeq ($(OS),Windows_NT)
|
||||||
|
@echo "Bootstrap completed successfully!"
|
||||||
|
@echo ""
|
||||||
|
@echo "Next steps:"
|
||||||
|
@echo " - Visit http://localhost:3000 to access the application"
|
||||||
|
@echo " - Run 'make help' to see all available commands"
|
||||||
|
else
|
||||||
|
@echo "$(GREEN)🎉 Bootstrap completed successfully!$(RESET)"
|
||||||
|
@echo ""
|
||||||
|
@echo "$(BOLD)Next steps:$(RESET)"
|
||||||
|
@echo " • Visit http://localhost:3000 to access the application"
|
||||||
|
@echo " • Run 'make help' to see all available commands"
|
||||||
|
endif
|
||||||
|
@echo ""
|
||||||
|
.PHONY: post-beautiful-bootstrap
|
||||||
|
|
||||||
|
bootstrap: ## Prepare the project for local development
|
||||||
|
bootstrap: \
|
||||||
|
pre-beautiful-bootstrap \
|
||||||
|
pre-bootstrap \
|
||||||
|
build \
|
||||||
|
post-bootstrap \
|
||||||
|
run \
|
||||||
|
post-beautiful-bootstrap
|
||||||
.PHONY: bootstrap
|
.PHONY: bootstrap
|
||||||
|
|
||||||
|
bootstrap-e2e: ## Prepare Docker production images to be used for e2e tests
|
||||||
|
bootstrap-e2e: \
|
||||||
|
pre-bootstrap \
|
||||||
|
build-e2e \
|
||||||
|
post-bootstrap \
|
||||||
|
run-e2e
|
||||||
|
.PHONY: bootstrap-e2e
|
||||||
|
|
||||||
# -- Docker/compose
|
# -- Docker/compose
|
||||||
build: cache ?= --no-cache
|
build: cache ?=
|
||||||
build: ## build the project containers
|
build: ## build the project containers
|
||||||
@$(MAKE) build-backend cache=$(cache)
|
@$(MAKE) build-backend cache=$(cache)
|
||||||
@$(MAKE) build-yjs-provider cache=$(cache)
|
@$(MAKE) build-yjs-provider cache=$(cache)
|
||||||
@@ -103,16 +189,23 @@ build-backend: ## build the app-dev container
|
|||||||
|
|
||||||
build-yjs-provider: cache ?=
|
build-yjs-provider: cache ?=
|
||||||
build-yjs-provider: ## build the y-provider container
|
build-yjs-provider: ## build the y-provider container
|
||||||
@$(COMPOSE) build y-provider $(cache)
|
@$(COMPOSE) build y-provider-development $(cache)
|
||||||
.PHONY: build-yjs-provider
|
.PHONY: build-yjs-provider
|
||||||
|
|
||||||
build-frontend: cache ?=
|
build-frontend: cache ?=
|
||||||
build-frontend: ## build the frontend container
|
build-frontend: ## build the frontend container
|
||||||
@$(COMPOSE) build frontend $(cache)
|
@$(COMPOSE) build frontend-development $(cache)
|
||||||
.PHONY: build-frontend
|
.PHONY: build-frontend
|
||||||
|
|
||||||
|
build-e2e: cache ?=
|
||||||
|
build-e2e: ## build the e2e container
|
||||||
|
@$(MAKE) build-backend cache=$(cache)
|
||||||
|
@$(COMPOSE_E2E) build frontend $(cache)
|
||||||
|
@$(COMPOSE_E2E) build y-provider $(cache)
|
||||||
|
.PHONY: build-e2e
|
||||||
|
|
||||||
down: ## stop and remove containers, networks, images, and volumes
|
down: ## stop and remove containers, networks, images, and volumes
|
||||||
@$(COMPOSE) down
|
@$(COMPOSE_E2E) down
|
||||||
.PHONY: down
|
.PHONY: down
|
||||||
|
|
||||||
logs: ## display app-dev logs (follow mode)
|
logs: ## display app-dev logs (follow mode)
|
||||||
@@ -121,22 +214,30 @@ logs: ## display app-dev logs (follow mode)
|
|||||||
|
|
||||||
run-backend: ## Start only the backend application and all needed services
|
run-backend: ## Start only the backend application and all needed services
|
||||||
@$(COMPOSE) up --force-recreate -d celery-dev
|
@$(COMPOSE) up --force-recreate -d celery-dev
|
||||||
@$(COMPOSE) up --force-recreate -d y-provider
|
@$(COMPOSE) up --force-recreate -d y-provider-development
|
||||||
@$(COMPOSE) up --force-recreate -d nginx
|
@$(COMPOSE) up --force-recreate -d nginx
|
||||||
.PHONY: run-backend
|
.PHONY: run-backend
|
||||||
|
|
||||||
run: ## start the wsgi (production) and development server
|
run: ## start the wsgi (production) and development server
|
||||||
run:
|
run:
|
||||||
@$(MAKE) run-backend
|
@$(MAKE) run-backend
|
||||||
@$(COMPOSE) up --force-recreate -d frontend
|
@$(COMPOSE) up --force-recreate -d frontend-development
|
||||||
.PHONY: run
|
.PHONY: run
|
||||||
|
|
||||||
|
run-e2e: ## start the e2e server
|
||||||
|
run-e2e:
|
||||||
|
@$(MAKE) run-backend
|
||||||
|
@$(COMPOSE_E2E) stop y-provider-development
|
||||||
|
@$(COMPOSE_E2E) up --force-recreate -d frontend
|
||||||
|
@$(COMPOSE_E2E) up --force-recreate -d y-provider
|
||||||
|
.PHONY: run-e2e
|
||||||
|
|
||||||
status: ## an alias for "docker compose ps"
|
status: ## an alias for "docker compose ps"
|
||||||
@$(COMPOSE) ps
|
@$(COMPOSE_E2E) ps
|
||||||
.PHONY: status
|
.PHONY: status
|
||||||
|
|
||||||
stop: ## stop the development server using Docker
|
stop: ## stop the development server using Docker
|
||||||
@$(COMPOSE) stop
|
@$(COMPOSE_E2E) stop
|
||||||
.PHONY: stop
|
.PHONY: stop
|
||||||
|
|
||||||
# -- Backend
|
# -- Backend
|
||||||
@@ -225,20 +326,6 @@ resetdb: ## flush database and create a superuser "admin"
|
|||||||
@${MAKE} superuser
|
@${MAKE} superuser
|
||||||
.PHONY: resetdb
|
.PHONY: resetdb
|
||||||
|
|
||||||
env.d/development/common:
|
|
||||||
cp -n env.d/development/common.dist env.d/development/common
|
|
||||||
|
|
||||||
env.d/development/postgresql:
|
|
||||||
cp -n env.d/development/postgresql.dist env.d/development/postgresql
|
|
||||||
|
|
||||||
env.d/development/kc_postgresql:
|
|
||||||
cp -n env.d/development/kc_postgresql.dist env.d/development/kc_postgresql
|
|
||||||
|
|
||||||
# -- Internationalization
|
|
||||||
|
|
||||||
env.d/development/crowdin:
|
|
||||||
cp -n env.d/development/crowdin.dist env.d/development/crowdin
|
|
||||||
|
|
||||||
crowdin-download: ## Download translated message from crowdin
|
crowdin-download: ## Download translated message from crowdin
|
||||||
@$(COMPOSE_RUN_CROWDIN) download -c crowdin/config.yml
|
@$(COMPOSE_RUN_CROWDIN) download -c crowdin/config.yml
|
||||||
.PHONY: crowdin-download
|
.PHONY: crowdin-download
|
||||||
@@ -315,10 +402,14 @@ frontend-lint: ## run the frontend linter
|
|||||||
.PHONY: frontend-lint
|
.PHONY: frontend-lint
|
||||||
|
|
||||||
run-frontend-development: ## Run the frontend in development mode
|
run-frontend-development: ## Run the frontend in development mode
|
||||||
@$(COMPOSE) stop frontend
|
@$(COMPOSE) stop frontend-development
|
||||||
cd $(PATH_FRONT_IMPRESS) && yarn dev
|
cd $(PATH_FRONT_IMPRESS) && yarn dev
|
||||||
.PHONY: run-frontend-development
|
.PHONY: run-frontend-development
|
||||||
|
|
||||||
|
frontend-test: ## Run the frontend tests
|
||||||
|
cd $(PATH_FRONT_IMPRESS) && yarn test
|
||||||
|
.PHONY: frontend-test
|
||||||
|
|
||||||
frontend-i18n-extract: ## Extract the frontend translation inside a json to be used for crowdin
|
frontend-i18n-extract: ## Extract the frontend translation inside a json to be used for crowdin
|
||||||
cd $(PATH_FRONT) && yarn i18n:extract
|
cd $(PATH_FRONT) && yarn i18n:extract
|
||||||
.PHONY: frontend-i18n-extract
|
.PHONY: frontend-i18n-extract
|
||||||
@@ -349,6 +440,6 @@ bump-packages-version: ## bump the version of the project - VERSION_TYPE can be
|
|||||||
cd ./src/frontend/apps/e2e/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
cd ./src/frontend/apps/e2e/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||||
cd ./src/frontend/apps/impress/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
cd ./src/frontend/apps/impress/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||||
cd ./src/frontend/servers/y-provider/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
cd ./src/frontend/servers/y-provider/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||||
cd ./src/frontend/packages/eslint-config-impress/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
cd ./src/frontend/packages/eslint-plugin-docs/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||||
cd ./src/frontend/packages/i18n/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
cd ./src/frontend/packages/i18n/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
|
||||||
.PHONY: bump-packages-version
|
.PHONY: bump-packages-version
|
||||||
|
|||||||
114
README.md
114
README.md
@@ -1,13 +1,19 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/suitenumerique/docs">
|
<a href="https://github.com/suitenumerique/docs">
|
||||||
<img alt="Docs" src="/docs/assets/docs-logo.png" width="300" />
|
<img alt="Docs" src="/docs/assets/banner-docs.png" width="100%" />
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
Welcome to Docs! The open source document editor where your notes can become knowledge through live collaboration
|
<a href="https://github.com/suitenumerique/docs/stargazers/">
|
||||||
|
<img src="https://img.shields.io/github/stars/suitenumerique/docs" alt="">
|
||||||
|
</a>
|
||||||
|
<a href='https://github.com/suitenumerique/docs/blob/main/CONTRIBUTING.md'><img alt='PRs Welcome' src='https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=shields'/></a>
|
||||||
|
<img alt="GitHub commit activity" src="https://img.shields.io/github/commit-activity/m/suitenumerique/docs"/>
|
||||||
|
<img alt="GitHub closed issues" src="https://img.shields.io/github/issues-closed/suitenumerique/docs"/>
|
||||||
|
<a href="https://github.com/suitenumerique/docs/blob/main/LICENSE">
|
||||||
|
<img alt="MIT License" src="https://img.shields.io/github/license/suitenumerique/docs"/>
|
||||||
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://matrix.to/#/#docs-official:matrix.org">
|
<a href="https://matrix.to/#/#docs-official:matrix.org">
|
||||||
Chat on Matrix
|
Chat on Matrix
|
||||||
@@ -20,46 +26,61 @@ Welcome to Docs! The open source document editor where your notes can become kno
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
# La Suite Docs : Collaborative Text Editing
|
||||||
|
Docs, where your notes can become knowledge through live collaboration.
|
||||||
|
|
||||||
<img src="/docs/assets/docs_live_collaboration_light.gif" width="100%" align="center"/>
|
<img src="/docs/assets/docs_live_collaboration_light.gif" width="100%" align="center"/>
|
||||||
|
|
||||||
## Why use Docs ❓
|
## Why use Docs ❓
|
||||||
|
|
||||||
Docs is a collaborative text editor designed to address common challenges in knowledge building and sharing.
|
Docs is a collaborative text editor designed to address common challenges in knowledge building and sharing.
|
||||||
|
|
||||||
### Write
|
### Write
|
||||||
* 😌 Simple collaborative editing without the formatting complexity of markdown
|
* 😌 Get simple, accessible online editing for your team.
|
||||||
* 🔌 Offline? No problem, keep writing, your edits will get synced when back online
|
* 💅 Create clean documents with beautiful formatting options.
|
||||||
* 💅 Create clean documents with limited but beautiful formatting options and focus on content
|
* 🖌️ Focus on your content using either the in-line editor, or [the Markdown syntax](https://www.markdownguide.org/basic-syntax/).
|
||||||
* 🧱 Built for productivity (markdown support, many block types, slash commands, keyboard shortcuts).
|
* 🧱 Quickly design your page thanks to the many block types, accessible from the `/` slash commands, as well as keyboard shortcuts.
|
||||||
* ✨ Save time thanks to our AI actions (generate, sum up, correct, translate)
|
* 🔌 Write offline! Your edits will be synced once you're back online.
|
||||||
|
* ✨ Save time thanks to our AI actions, such as rephrasing, summarizing, fixing typos, translating, etc. You can even turn your selected text into a prompt!
|
||||||
|
|
||||||
### Collaborate
|
### Work together
|
||||||
* 🤝 Collaborate with your team in real time
|
* 🤝 Enjoy live editing! See your team collaborate in real time.
|
||||||
* 🔒 Granular access control to ensure your information is secure and only shared with the right people
|
* 🔒 Keep your information secure thanks to granular access control. Only share with the right people.
|
||||||
* 📑 Professional document exports in multiple formats (.odt, .doc, .pdf) with customizable templates
|
* 📑 Export your content in multiple formats (`.odt`, `.docx`, `.pdf`) with customizable templates.
|
||||||
* 📚 Built-in wiki functionality to turn your team's collaborative work into organized knowledge `ETA 02/2025`
|
* 📚 Turn your team's collaborative work into organized knowledge with Subpages.
|
||||||
|
|
||||||
### Self-host
|
### Self-host
|
||||||
* 🚀 Easy to install, scalable and secure alternative to Notion, Outline or Confluence
|
|
||||||
|
#### 🚀 Docs is easy to install on your own servers
|
||||||
|
We use Kubernetes for our [production instance](https://docs.numerique.gouv.fr/) but also support Docker Compose. The community contributed a couple other methods (Nix, YunoHost etc.) check out the [docs](/docs/installation/README.md) to get detailed instructions and examples.
|
||||||
|
|
||||||
|
#### 🌍 Known instances
|
||||||
|
We hope to see many more, here is an incomplete list of public Docs instances. Feel free to make a PR to add ones that are not listed below🙏
|
||||||
|
|
||||||
|
| Url | Org | Public |
|
||||||
|
| --- | --- | ------- |
|
||||||
|
| [docs.numerique.gouv.fr](https://docs.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up|
|
||||||
|
| [docs.suite.anct.gouv.fr](https://docs.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up|
|
||||||
|
| [notes.demo.opendesk.eu](https://notes.demo.opendesk.eu) | ZenDiS | Demo instance of OpenDesk. Request access to get credentials |
|
||||||
|
| [notes.liiib.re](https://notes.liiib.re/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
|
||||||
|
| [docs.federated.nexus](https://docs.federated.nexus/) | federated.nexus | Public instance, but you have to [sign up for a Federated Nexus account](https://federated.nexus/register/). |
|
||||||
|
| [docs.demo.mosacloud.eu](https://docs.demo.mosacloud.eu/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
|
||||||
|
|
||||||
|
#### ⚠️ Advanced features
|
||||||
|
For some advanced features (ex: Export as PDF) Docs relies on XL packages from BlockNote. These are licenced under GPL and are not MIT compatible. You can perfectly use Docs without these packages by setting the environment variable `PUBLISH_AS_MIT` to true. That way you'll build an image of the application without the features that are not MIT compatible. Read the [environment variables documentation](/docs/env.md) for more information.
|
||||||
|
|
||||||
## Getting started 🔧
|
## Getting started 🔧
|
||||||
|
|
||||||
### Test it
|
### Test it
|
||||||
|
|
||||||
Test Docs on your browser by logging in on this [environment](https://impress-preprod.beta.numerique.gouv.fr/)
|
You can test Docs on your browser by visiting this [demo document](https://impress-preprod.beta.numerique.gouv.fr/docs/6ee5aac4-4fb9-457d-95bf-bb56c2467713/)
|
||||||
|
|
||||||
```
|
### Run Docs locally
|
||||||
email: test.docs@yopmail.com
|
|
||||||
password: I'd<3ToTestDocs
|
|
||||||
```
|
|
||||||
|
|
||||||
### Run it locally
|
> ⚠️ The methods described below for running Docs locally is **for testing purposes only**. It is based on building Docs using [Minio](https://min.io/) as an S3-compatible storage solution. Of course you can choose any S3-compatible storage solution.
|
||||||
|
|
||||||
> ⚠️ Running Docs locally using the methods described below is for testing purposes only. It is based on building Docs using Minio as the S3 storage solution but you can choose any S3 compatible object storage of your choice.
|
|
||||||
|
|
||||||
**Prerequisite**
|
**Prerequisite**
|
||||||
|
|
||||||
Make sure you have a recent version of Docker and [Docker Compose](https://docs.docker.com/compose/install) installed on your laptop:
|
Make sure you have a recent version of Docker and [Docker Compose](https://docs.docker.com/compose/install) installed on your laptop, then type:
|
||||||
|
|
||||||
```shellscript
|
```shellscript
|
||||||
$ docker -v
|
$ docker -v
|
||||||
@@ -71,7 +92,7 @@ $ docker compose version
|
|||||||
Docker Compose version v2.32.4
|
Docker Compose version v2.32.4
|
||||||
```
|
```
|
||||||
|
|
||||||
> ⚠️ You may need to run the following commands with sudo but this can be avoided by adding your user to the `docker` group.
|
> ⚠️ You may need to run the following commands with `sudo`, but this can be avoided by adding your user to the local `docker` group.
|
||||||
|
|
||||||
**Project bootstrap**
|
**Project bootstrap**
|
||||||
|
|
||||||
@@ -81,20 +102,20 @@ The easiest way to start working on the project is to use [GNU Make](https://www
|
|||||||
$ make bootstrap FLUSH_ARGS='--no-input'
|
$ make bootstrap FLUSH_ARGS='--no-input'
|
||||||
```
|
```
|
||||||
|
|
||||||
This command builds the `app` container, installs dependencies, performs database migrations and compile translations. It's a good idea to use this command each time you are pulling code from the project repository to avoid dependency-related or migration-related issues.
|
This command builds the `app-dev` and `frontend-dev` containers, installs dependencies, performs database migrations and compiles translations. It's a good idea to use this command each time you are pulling code from the project repository to avoid dependency-related or migration-related issues.
|
||||||
|
|
||||||
Your Docker services should now be up and running 🎉
|
Your Docker services should now be up and running 🎉
|
||||||
|
|
||||||
You can access to the project by going to <http://localhost:3000>.
|
You can access the project by going to <http://localhost:3000>.
|
||||||
|
|
||||||
You will be prompted to log in, the default credentials are:
|
You will be prompted to log in. The default credentials are:
|
||||||
|
|
||||||
```
|
```
|
||||||
username: impress
|
username: impress
|
||||||
password: impress
|
password: impress
|
||||||
```
|
```
|
||||||
|
|
||||||
📝 Note that if you need to run them afterwards, you can use the eponym Make rule:
|
📝 Note that if you need to run them afterwards, you can use the eponymous Make rule:
|
||||||
|
|
||||||
```shellscript
|
```shellscript
|
||||||
$ make run
|
$ make run
|
||||||
@@ -120,14 +141,21 @@ To start all the services, except the frontend container, you can use the follow
|
|||||||
$ make run-backend
|
$ make run-backend
|
||||||
```
|
```
|
||||||
|
|
||||||
|
To execute frontend tests & linting only
|
||||||
|
```shellscript
|
||||||
|
$ make frontend-test
|
||||||
|
$ make frontend-lint
|
||||||
|
```
|
||||||
|
|
||||||
**Adding content**
|
**Adding content**
|
||||||
You can create a basic demo site by running:
|
|
||||||
|
You can create a basic demo site by running this command:
|
||||||
|
|
||||||
```shellscript
|
```shellscript
|
||||||
$ make demo
|
$ make demo
|
||||||
```
|
```
|
||||||
|
|
||||||
Finally, you can check all available Make rules using:
|
Finally, you can check all available Make rules using this command:
|
||||||
|
|
||||||
```shellscript
|
```shellscript
|
||||||
$ make help
|
$ make help
|
||||||
@@ -135,7 +163,7 @@ $ make help
|
|||||||
|
|
||||||
**Django admin**
|
**Django admin**
|
||||||
|
|
||||||
You can access the Django admin site at
|
You can access the Django admin site at:
|
||||||
|
|
||||||
<http://localhost:8071/admin>.
|
<http://localhost:8071/admin>.
|
||||||
|
|
||||||
@@ -147,17 +175,17 @@ $ make superuser
|
|||||||
|
|
||||||
## Feedback 🙋♂️🙋♀️
|
## Feedback 🙋♂️🙋♀️
|
||||||
|
|
||||||
We'd love to hear your thoughts and hear about your experiments, so come and say hi on [Matrix](https://matrix.to/#/#docs-official:matrix.org).
|
We'd love to hear your thoughts, and hear about your experiments, so come and say hi on [Matrix](https://matrix.to/#/#docs-official:matrix.org).
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap 💡
|
||||||
|
|
||||||
Want to know where the project is headed? [🗺️ Checkout our roadmap](https://github.com/orgs/numerique-gouv/projects/13/views/11)
|
Want to know where the project is headed? [🗺️ Checkout our roadmap](https://github.com/orgs/numerique-gouv/projects/13/views/11)
|
||||||
|
|
||||||
## Licence 📝
|
## License 📝
|
||||||
|
|
||||||
This work is released under the MIT License (see [LICENSE](https://github.com/suitenumerique/docs/blob/main/LICENSE)).
|
This work is released under the MIT License (see [LICENSE](https://github.com/suitenumerique/docs/blob/main/LICENSE)).
|
||||||
|
|
||||||
While Docs is a public driven initiative our licence choice is an invitation for private sector actors to use, sell and contribute to the project.
|
While Docs is a public-driven initiative, our license choice is an invitation for private sector actors to use, sell and contribute to the project.
|
||||||
|
|
||||||
## Contributing 🙌
|
## Contributing 🙌
|
||||||
|
|
||||||
@@ -165,9 +193,9 @@ This project is intended to be community-driven, so please, do not hesitate to [
|
|||||||
|
|
||||||
You can help us with translations on [Crowdin](https://crowdin.com/project/lasuite-docs).
|
You can help us with translations on [Crowdin](https://crowdin.com/project/lasuite-docs).
|
||||||
|
|
||||||
If you intend to make pull requests see [CONTRIBUTING](https://github.com/suitenumerique/docs/blob/main/CONTRIBUTING.md) for guidelines.
|
If you intend to make pull requests, see [CONTRIBUTING](https://github.com/suitenumerique/docs/blob/main/CONTRIBUTING.md) for guidelines.
|
||||||
|
|
||||||
Directory structure:
|
## Directory structure:
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
docs
|
docs
|
||||||
@@ -185,14 +213,14 @@ docs
|
|||||||
|
|
||||||
### Stack
|
### Stack
|
||||||
|
|
||||||
Docs is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/), [BlockNote.js](https://www.blocknotejs.org/), [HocusPocus](https://tiptap.dev/docs/hocuspocus/introduction) and [Yjs](https://yjs.dev/).
|
Docs is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/), [BlockNote.js](https://www.blocknotejs.org/), [HocusPocus](https://tiptap.dev/docs/hocuspocus/introduction) and [Yjs](https://yjs.dev/). We thank the contributors of all these projects for their awesome work!
|
||||||
|
|
||||||
|
We are proud sponsors of [BlockNotejs](https://www.blocknotejs.org/) and [Yjs](https://yjs.dev/).
|
||||||
|
|
||||||
|
|
||||||
### Gov ❤️ open source
|
### Gov ❤️ open source
|
||||||
|
|
||||||
Docs is the result of a joint effort led by the French 🇫🇷🥖 ([DINUM](https://www.numerique.gouv.fr/dinum/)) and German 🇩🇪🥨 governments ([ZenDiS](https://zendis.de/)).
|
Docs is the result of a joint effort led by the French 🇫🇷🥖 ([DINUM](https://www.numerique.gouv.fr/dinum/)) and German 🇩🇪🥨 governments ([ZenDiS](https://zendis.de/)).
|
||||||
|
|
||||||
We are proud sponsors of [BlockNotejs](https://www.blocknotejs.org/) and [Yjs](https://yjs.dev/).
|
|
||||||
|
|
||||||
We are always looking for new public partners (we are currently onboarding the Netherlands 🇳🇱🧀), feel free to [reach out](mailto:docs@numerique.gouv.fr) if you are interested in using or contributing to Docs.
|
We are always looking for new public partners (we are currently onboarding the Netherlands 🇳🇱🧀), feel free to [reach out](mailto:docs@numerique.gouv.fr) if you are interested in using or contributing to Docs.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
Security is very important to us.
|
Security is very important to us.
|
||||||
|
|
||||||
If you have any issue regarding security, please disclose the information responsibly submiting [this form](https://vdp.numerique.gouv.fr/p/Send-a-report?lang=en) and not by creating an issue on the repository. You can also email us at docs@numerique.gouv.fr
|
If you have any issue regarding security, please disclose the information responsibly submitting [this form](https://vdp.numerique.gouv.fr/p/Send-a-report?lang=en) and not by creating an issue on the repository. You can also email us at docs@numerique.gouv.fr
|
||||||
|
|
||||||
We appreciate your effort to make Docs more secure.
|
We appreciate your effort to make Docs more secure.
|
||||||
|
|
||||||
|
|||||||
21
UPGRADE.md
21
UPGRADE.md
@@ -16,6 +16,27 @@ the following command inside your docker container:
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [3.3.0] - 2025-05-22
|
||||||
|
|
||||||
|
⚠️ For some advanced features (ex: Export as PDF) Docs relies on XL packages from BlockNote. These are licenced under AGPL-3.0 and are not MIT compatible. You can perfectly use Docs without these packages by setting the environment variable `PUBLISH_AS_MIT` to true. That way you'll build an image of the application without the features that are not MIT compatible. Read the [environment variables documentation](/docs/env.md) for more information.
|
||||||
|
|
||||||
|
The footer is now configurable from a customization file. To override the default one, you can
|
||||||
|
use the `THEME_CUSTOMIZATION_FILE_PATH` environment variable to point to your customization file.
|
||||||
|
The customization file must be a JSON file and must follow the rules described in the
|
||||||
|
[theming documentation](docs/theming.md).
|
||||||
|
|
||||||
|
## [3.0.0] - 2025-03-28
|
||||||
|
|
||||||
|
We are not using the nginx auth request anymore to access the collaboration server (`yProvider`)
|
||||||
|
The authentication is now managed directly from the yProvider server.
|
||||||
|
You must remove the annotation `nginx.ingress.kubernetes.io/auth-url` from the `ingressCollaborationWS`.
|
||||||
|
|
||||||
|
This means as well that the yProvider server must be able to access the Django server.
|
||||||
|
To do so, you must set the `COLLABORATION_BACKEND_BASE_URL` environment variable to the `yProvider`
|
||||||
|
service.
|
||||||
|
|
||||||
|
## [2.2.0] - 2025-02-10
|
||||||
|
|
||||||
- AI features are now limited to users who are authenticated. Before this release, even anonymous
|
- AI features are now limited to users who are authenticated. Before this release, even anonymous
|
||||||
users who gained editor access on a document with link reach used to get AI feature.
|
users who gained editor access on a document with link reach used to get AI feature.
|
||||||
IF you want anonymous users to keep access on AI features, you must now define the
|
IF you want anonymous users to keep access on AI features, you must now define the
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ docker_build(
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
k8s_resource('impress-docs-backend-migrate', resource_deps=['dev-backend-postgres'])
|
||||||
|
k8s_resource('impress-docs-backend-createsuperuser', resource_deps=['impress-docs-backend-migrate'])
|
||||||
|
k8s_resource('dev-backend-keycloak', resource_deps=['dev-backend-keycloak-pg'])
|
||||||
|
k8s_resource('impress-docs-backend', resource_deps=['impress-docs-backend-migrate', 'dev-backend-redis', 'dev-backend-keycloak', 'dev-backend-postgres', 'dev-backend-minio:statefulset'])
|
||||||
k8s_yaml(local('cd ../src/helm && helmfile -n impress -e dev template .'))
|
k8s_yaml(local('cd ../src/helm && helmfile -n impress -e dev template .'))
|
||||||
|
|
||||||
migration = '''
|
migration = '''
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
|
|||||||
UNSET_USER=0
|
UNSET_USER=0
|
||||||
|
|
||||||
TERRAFORM_DIRECTORY="./env.d/terraform"
|
TERRAFORM_DIRECTORY="./env.d/terraform"
|
||||||
COMPOSE_FILE="${REPO_DIR}/docker-compose.yml"
|
COMPOSE_FILE="${REPO_DIR}/compose.yml"
|
||||||
|
|
||||||
|
|
||||||
# _set_user: set (or unset) default user id used to run docker commands
|
# _set_user: set (or unset) default user id used to run docker commands
|
||||||
@@ -38,6 +38,10 @@ function _set_user() {
|
|||||||
# options: docker compose command options
|
# options: docker compose command options
|
||||||
# ARGS : docker compose command arguments
|
# ARGS : docker compose command arguments
|
||||||
function _docker_compose() {
|
function _docker_compose() {
|
||||||
|
# Set DOCKER_USER for Windows compatibility with MinIO
|
||||||
|
if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" || -n "${WSL_DISTRO_NAME:-}" ]]; then
|
||||||
|
export DOCKER_USER="0:0"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "🐳(compose) file: '${COMPOSE_FILE}'"
|
echo "🐳(compose) file: '${COMPOSE_FILE}'"
|
||||||
docker compose \
|
docker compose \
|
||||||
|
|||||||
29
compose-e2e.yml
Normal file
29
compose-e2e.yml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
services:
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
user: "${DOCKER_USER:-1000}"
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: ./src/frontend/Dockerfile
|
||||||
|
target: frontend-production
|
||||||
|
args:
|
||||||
|
API_ORIGIN: "http://localhost:8071"
|
||||||
|
PUBLISH_AS_MIT: "false"
|
||||||
|
SW_DEACTIVATED: "true"
|
||||||
|
image: impress:frontend-production
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
|
||||||
|
y-provider:
|
||||||
|
user: ${DOCKER_USER:-1000}
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: ./src/frontend/servers/y-provider/Dockerfile
|
||||||
|
target: y-provider
|
||||||
|
image: impress:y-provider-production
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- env.d/development/common
|
||||||
|
- env.d/development/common.local
|
||||||
|
ports:
|
||||||
|
- "4444:4444"
|
||||||
@@ -10,6 +10,7 @@ services:
|
|||||||
retries: 300
|
retries: 300
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/development/postgresql
|
- env.d/development/postgresql
|
||||||
|
- env.d/development/postgresql.local
|
||||||
ports:
|
ports:
|
||||||
- "15432:5432"
|
- "15432:5432"
|
||||||
|
|
||||||
@@ -66,7 +67,9 @@ services:
|
|||||||
- DJANGO_CONFIGURATION=Development
|
- DJANGO_CONFIGURATION=Development
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/development/common
|
- env.d/development/common
|
||||||
|
- env.d/development/common.local
|
||||||
- env.d/development/postgresql
|
- env.d/development/postgresql
|
||||||
|
- env.d/development/postgresql.local
|
||||||
ports:
|
ports:
|
||||||
- "8071:8000"
|
- "8071:8000"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -91,47 +94,15 @@ services:
|
|||||||
- DJANGO_CONFIGURATION=Development
|
- DJANGO_CONFIGURATION=Development
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/development/common
|
- env.d/development/common
|
||||||
|
- env.d/development/common.local
|
||||||
- env.d/development/postgresql
|
- env.d/development/postgresql
|
||||||
|
- env.d/development/postgresql.local
|
||||||
volumes:
|
volumes:
|
||||||
- ./src/backend:/app
|
- ./src/backend:/app
|
||||||
- ./data/static:/data/static
|
- ./data/static:/data/static
|
||||||
depends_on:
|
depends_on:
|
||||||
- app-dev
|
- app-dev
|
||||||
|
|
||||||
app:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
target: backend-production
|
|
||||||
args:
|
|
||||||
DOCKER_USER: ${DOCKER_USER:-1000}
|
|
||||||
user: ${DOCKER_USER:-1000}
|
|
||||||
image: impress:backend-production
|
|
||||||
environment:
|
|
||||||
- DJANGO_CONFIGURATION=Demo
|
|
||||||
env_file:
|
|
||||||
- env.d/development/common
|
|
||||||
- env.d/development/postgresql
|
|
||||||
depends_on:
|
|
||||||
postgresql:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: true
|
|
||||||
redis:
|
|
||||||
condition: service_started
|
|
||||||
minio:
|
|
||||||
condition: service_started
|
|
||||||
|
|
||||||
celery:
|
|
||||||
user: ${DOCKER_USER:-1000}
|
|
||||||
image: impress:backend-production
|
|
||||||
command: ["celery", "-A", "impress.celery_app", "worker", "-l", "INFO"]
|
|
||||||
environment:
|
|
||||||
- DJANGO_CONFIGURATION=Demo
|
|
||||||
env_file:
|
|
||||||
- env.d/development/common
|
|
||||||
- env.d/development/postgresql
|
|
||||||
depends_on:
|
|
||||||
- app
|
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
image: nginx:1.25
|
image: nginx:1.25
|
||||||
ports:
|
ports:
|
||||||
@@ -141,24 +112,25 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
app-dev:
|
app-dev:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
y-provider:
|
|
||||||
condition: service_started
|
|
||||||
keycloak:
|
keycloak:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: true
|
restart: true
|
||||||
|
|
||||||
frontend:
|
frontend-development:
|
||||||
user: "${DOCKER_USER:-1000}"
|
user: "${DOCKER_USER:-1000}"
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: ./src/frontend/Dockerfile
|
dockerfile: ./src/frontend/Dockerfile
|
||||||
target: frontend-production
|
target: impress-dev
|
||||||
args:
|
args:
|
||||||
API_ORIGIN: "http://localhost:8071"
|
API_ORIGIN: "http://localhost:8071"
|
||||||
Y_PROVIDER_URL: "ws://localhost:4444"
|
PUBLISH_AS_MIT: "false"
|
||||||
MEDIA_URL: "http://localhost:8083"
|
|
||||||
SW_DEACTIVATED: "true"
|
SW_DEACTIVATED: "true"
|
||||||
image: impress:frontend-development
|
image: impress:frontend-development
|
||||||
|
volumes:
|
||||||
|
- ./src/frontend:/home/frontend
|
||||||
|
- /home/frontend/node_modules
|
||||||
|
- /home/frontend/apps/impress/node_modules
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
|
|
||||||
@@ -168,28 +140,35 @@ services:
|
|||||||
- ".:/app"
|
- ".:/app"
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/development/crowdin
|
- env.d/development/crowdin
|
||||||
|
- env.d/development/crowdin.local
|
||||||
user: "${DOCKER_USER:-1000}"
|
user: "${DOCKER_USER:-1000}"
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
|
|
||||||
node:
|
node:
|
||||||
image: node:18
|
image: node:22
|
||||||
user: "${DOCKER_USER:-1000}"
|
user: "${DOCKER_USER:-1000}"
|
||||||
environment:
|
environment:
|
||||||
HOME: /tmp
|
HOME: /tmp
|
||||||
volumes:
|
volumes:
|
||||||
- ".:/app"
|
- ".:/app"
|
||||||
|
|
||||||
y-provider:
|
y-provider-development:
|
||||||
user: ${DOCKER_USER:-1000}
|
user: ${DOCKER_USER:-1000}
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: ./src/frontend/servers/y-provider/Dockerfile
|
dockerfile: ./src/frontend/servers/y-provider/Dockerfile
|
||||||
target: y-provider
|
target: y-provider-development
|
||||||
|
image: impress:y-provider-development
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/development/common
|
- env.d/development/common
|
||||||
|
- env.d/development/common.local
|
||||||
ports:
|
ports:
|
||||||
- "4444:4444"
|
- "4444:4444"
|
||||||
|
volumes:
|
||||||
|
- ./src/frontend/:/home/frontend
|
||||||
|
- /home/frontend/node_modules
|
||||||
|
- /home/frontend/servers/y-provider/node_modules
|
||||||
|
|
||||||
kc_postgresql:
|
kc_postgresql:
|
||||||
image: postgres:14.3
|
image: postgres:14.3
|
||||||
@@ -202,24 +181,23 @@ services:
|
|||||||
- "5433:5432"
|
- "5433:5432"
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/development/kc_postgresql
|
- env.d/development/kc_postgresql
|
||||||
|
- env.d/development/kc_postgresql.local
|
||||||
|
|
||||||
keycloak:
|
keycloak:
|
||||||
image: quay.io/keycloak/keycloak:20.0.1
|
image: quay.io/keycloak/keycloak:26.3
|
||||||
volumes:
|
volumes:
|
||||||
- ./docker/auth/realm.json:/opt/keycloak/data/import/realm.json
|
- ./docker/auth/realm.json:/opt/keycloak/data/import/realm.json
|
||||||
command:
|
command:
|
||||||
- start-dev
|
- start-dev
|
||||||
- --features=preview
|
- --features=preview
|
||||||
- --import-realm
|
- --import-realm
|
||||||
- --proxy=edge
|
- --hostname=http://localhost:8083
|
||||||
- --hostname-url=http://localhost:8083
|
|
||||||
- --hostname-admin-url=http://localhost:8083/
|
|
||||||
- --hostname-strict=false
|
- --hostname-strict=false
|
||||||
- --hostname-strict-https=false
|
|
||||||
- --health-enabled=true
|
- --health-enabled=true
|
||||||
- --metrics-enabled=true
|
- --metrics-enabled=true
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "--head", "-fsS", "http://localhost:8080/health/ready"]
|
test: ['CMD-SHELL', 'exec 3<>/dev/tcp/localhost/9000; echo -e "GET /health/live HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" >&3; grep "HTTP/1.1 200 OK" <&3']
|
||||||
|
start_period: 5s
|
||||||
interval: 1s
|
interval: 1s
|
||||||
timeout: 2s
|
timeout: 2s
|
||||||
retries: 300
|
retries: 300
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
"oauth2DeviceCodeLifespan": 600,
|
"oauth2DeviceCodeLifespan": 600,
|
||||||
"oauth2DevicePollingInterval": 5,
|
"oauth2DevicePollingInterval": 5,
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"sslRequired": "external",
|
"sslRequired": "none",
|
||||||
"registrationAllowed": true,
|
"registrationAllowed": true,
|
||||||
"registrationEmailAsUsername": false,
|
"registrationEmailAsUsername": false,
|
||||||
"rememberMe": true,
|
"rememberMe": true,
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "user-e2e-chromium",
|
"username": "user-e2e-chromium",
|
||||||
"email": "user@chromium.e2e",
|
"email": "user.test@chromium.test",
|
||||||
"firstName": "E2E",
|
"firstName": "E2E",
|
||||||
"lastName": "Chromium",
|
"lastName": "Chromium",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "user-e2e-webkit",
|
"username": "user-e2e-webkit",
|
||||||
"email": "user@webkit.e2e",
|
"email": "user.test@webkit.test",
|
||||||
"firstName": "E2E",
|
"firstName": "E2E",
|
||||||
"lastName": "Webkit",
|
"lastName": "Webkit",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "user-e2e-firefox",
|
"username": "user-e2e-firefox",
|
||||||
"email": "user@firefox.e2e",
|
"email": "user.test@firefox.test",
|
||||||
"firstName": "E2E",
|
"firstName": "E2E",
|
||||||
"lastName": "Firefox",
|
"lastName": "Firefox",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -2270,7 +2270,7 @@
|
|||||||
"cibaInterval": "5",
|
"cibaInterval": "5",
|
||||||
"realmReusableOtpCode": "false"
|
"realmReusableOtpCode": "false"
|
||||||
},
|
},
|
||||||
"keycloakVersion": "20.0.1",
|
"keycloakVersion": "26.3.2",
|
||||||
"userManagedAccessAllowed": false,
|
"userManagedAccessAllowed": false,
|
||||||
"clientProfiles": {
|
"clientProfiles": {
|
||||||
"profiles": []
|
"profiles": []
|
||||||
|
|||||||
@@ -4,54 +4,6 @@ server {
|
|||||||
server_name localhost;
|
server_name localhost;
|
||||||
charset utf-8;
|
charset utf-8;
|
||||||
|
|
||||||
# Proxy auth for collaboration server
|
|
||||||
location /collaboration/ws/ {
|
|
||||||
# Collaboration Auth request configuration
|
|
||||||
auth_request /collaboration-auth;
|
|
||||||
auth_request_set $authHeader $upstream_http_authorization;
|
|
||||||
auth_request_set $canEdit $upstream_http_x_can_edit;
|
|
||||||
auth_request_set $userId $upstream_http_x_user_id;
|
|
||||||
|
|
||||||
# Pass specific headers from the auth response
|
|
||||||
proxy_set_header Authorization $authHeader;
|
|
||||||
proxy_set_header X-Can-Edit $canEdit;
|
|
||||||
proxy_set_header X-User-Id $userId;
|
|
||||||
|
|
||||||
# Ensure WebSocket upgrade
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "Upgrade";
|
|
||||||
|
|
||||||
# Collaboration server
|
|
||||||
proxy_pass http://y-provider:4444;
|
|
||||||
|
|
||||||
# Set appropriate timeout for WebSocket
|
|
||||||
proxy_read_timeout 86400;
|
|
||||||
proxy_send_timeout 86400;
|
|
||||||
|
|
||||||
# Preserve original host and additional headers
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
}
|
|
||||||
|
|
||||||
location /collaboration-auth {
|
|
||||||
proxy_pass http://app-dev:8000/api/v1.0/documents/collaboration-auth/;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Original-URL $request_uri;
|
|
||||||
|
|
||||||
# Prevent the body from being passed
|
|
||||||
proxy_pass_request_body off;
|
|
||||||
proxy_set_header Content-Length "";
|
|
||||||
proxy_set_header X-Original-Method $request_method;
|
|
||||||
}
|
|
||||||
|
|
||||||
location /collaboration/api/ {
|
|
||||||
# Collaboration server
|
|
||||||
proxy_pass http://y-provider:4444;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Proxy auth for media
|
# Proxy auth for media
|
||||||
location /media/ {
|
location /media/ {
|
||||||
# Auth request configuration
|
# Auth request configuration
|
||||||
|
|||||||
115
docker/files/production/etc/nginx/conf.d/default.conf.template
Normal file
115
docker/files/production/etc/nginx/conf.d/default.conf.template
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
upstream docs_backend {
|
||||||
|
server ${BACKEND_HOST}:8000 fail_timeout=0;
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream docs_frontend {
|
||||||
|
server ${FRONTEND_HOST}:3000 fail_timeout=0;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 8083;
|
||||||
|
server_name localhost;
|
||||||
|
charset utf-8;
|
||||||
|
|
||||||
|
# increase max upload size
|
||||||
|
client_max_body_size 10m;
|
||||||
|
|
||||||
|
# Disables server version feedback on pages and in headers
|
||||||
|
server_tokens off;
|
||||||
|
|
||||||
|
proxy_ssl_server_name on;
|
||||||
|
|
||||||
|
location @proxy_to_docs_backend {
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|
||||||
|
proxy_redirect off;
|
||||||
|
proxy_pass http://docs_backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
location @proxy_to_docs_frontend {
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|
||||||
|
proxy_redirect off;
|
||||||
|
proxy_pass http://docs_frontend;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri @proxy_to_docs_frontend;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api {
|
||||||
|
try_files $uri @proxy_to_docs_backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /admin {
|
||||||
|
try_files $uri @proxy_to_docs_backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /static {
|
||||||
|
try_files $uri @proxy_to_docs_backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy auth for collaboration server
|
||||||
|
location /collaboration/ws/ {
|
||||||
|
# Ensure WebSocket upgrade
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "Upgrade";
|
||||||
|
|
||||||
|
# Collaboration server
|
||||||
|
proxy_pass http://${YPROVIDER_HOST}:4444;
|
||||||
|
|
||||||
|
# Set appropriate timeout for WebSocket
|
||||||
|
proxy_read_timeout 86400;
|
||||||
|
proxy_send_timeout 86400;
|
||||||
|
|
||||||
|
# Preserve original host and additional headers
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
proxy_set_header Origin $http_origin;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /collaboration/api/ {
|
||||||
|
# Collaboration server
|
||||||
|
proxy_pass http://${YPROVIDER_HOST}:4444;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy auth for media
|
||||||
|
location /media/ {
|
||||||
|
# Auth request configuration
|
||||||
|
auth_request /media-auth;
|
||||||
|
auth_request_set $authHeader $upstream_http_authorization;
|
||||||
|
auth_request_set $authDate $upstream_http_x_amz_date;
|
||||||
|
auth_request_set $authContentSha256 $upstream_http_x_amz_content_sha256;
|
||||||
|
|
||||||
|
# Pass specific headers from the auth response
|
||||||
|
proxy_set_header Authorization $authHeader;
|
||||||
|
proxy_set_header X-Amz-Date $authDate;
|
||||||
|
proxy_set_header X-Amz-Content-SHA256 $authContentSha256;
|
||||||
|
|
||||||
|
# Get resource from Minio
|
||||||
|
proxy_pass https://${S3_HOST}/${BUCKET_NAME}/;
|
||||||
|
proxy_set_header Host ${S3_HOST};
|
||||||
|
|
||||||
|
proxy_ssl_name ${S3_HOST};
|
||||||
|
|
||||||
|
add_header Content-Security-Policy "default-src 'none'" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /media-auth {
|
||||||
|
proxy_pass http://docs_backend/api/v1.0/documents/media-auth/;
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Original-URL $request_uri;
|
||||||
|
|
||||||
|
# Prevent the body from being passed
|
||||||
|
proxy_pass_request_body off;
|
||||||
|
proxy_set_header Content-Length "";
|
||||||
|
proxy_set_header X-Original-Method $request_method;
|
||||||
|
}
|
||||||
|
}
|
||||||
193
docs/adr/ADR-0001-20250106-use-yjs-for-docs-editing.md
Normal file
193
docs/adr/ADR-0001-20250106-use-yjs-for-docs-editing.md
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
## Decision TLDR;
|
||||||
|
|
||||||
|
We will use Yjs a CRDT-based library for the collaborative editing of the documents.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
We need to implement a collaborative editing feature for the documents that supports real-time collaboration, offline capabilities, and seamless integration with our Django backend.
|
||||||
|
|
||||||
|
## Considered alternatives
|
||||||
|
|
||||||
|
### ProseMirror
|
||||||
|
|
||||||
|
A robust toolkit for building rich-text editors with collaboration capabilities.
|
||||||
|
|
||||||
|
| Pros | Cons |
|
||||||
|
| --- | --- |
|
||||||
|
| Mature ecosystem | Complex integration with Django |
|
||||||
|
| Rich text editing features | Steeper learning curve |
|
||||||
|
| Used by major companies | More complex to implement offline support |
|
||||||
|
| Large community | |
|
||||||
|
|
||||||
|
### ShareDB
|
||||||
|
|
||||||
|
Real-time database backend based on Operational Transformation.
|
||||||
|
|
||||||
|
| Pros | Cons |
|
||||||
|
| --- | --- |
|
||||||
|
| Battle-tested in production | Complex setup required |
|
||||||
|
| Strong consistency model | Requires specific backend architecture |
|
||||||
|
| Good documentation | Less flexible with different backends |
|
||||||
|
| | Higher latency compared to CRDTs |
|
||||||
|
|
||||||
|
### Convergence
|
||||||
|
|
||||||
|
Complete enterprise solution for real-time collaboration.
|
||||||
|
|
||||||
|
| Pros | Cons |
|
||||||
|
| --- | --- |
|
||||||
|
| Full-featured solution | Commercial licensing |
|
||||||
|
| Built-in presence features | Less community support |
|
||||||
|
| Enterprise support | More expensive |
|
||||||
|
| Good offline support | Overkill for basic needs |
|
||||||
|
|
||||||
|
### CRDT-based Solutions Comparison
|
||||||
|
|
||||||
|
A CRDT-based library specifically designed for real-time collaboration.
|
||||||
|
|
||||||
|
| Category | Pros | Cons |
|
||||||
|
|----------|------|------|
|
||||||
|
| Technical Implementation | • Native real-time collaboration<br>• No central conflict resolution needed<br>• Works well with Django backend<br>• Automatic state synchronization | • Learning curve for CRDT concepts<br>• More complex initial setup<br>• Additional metadata overhead |
|
||||||
|
| User Experience | • Instant local updates<br>• Works offline by default<br>• Low latency<br>• Smooth concurrent editing | • Eventual consistency might cause brief inconsistencies<br>• UI must handle temporary conflicts |
|
||||||
|
| Performance | • Excellent scaling with multiple users<br>• Reduced server load<br>• Efficient network usage<br>• Good memory optimization (especially Yjs) | • Slightly higher memory usage<br>• Initial state sync can be larger |
|
||||||
|
| Development | • No need to build conflict resolution<br>• Simple integration with text editors<br>• Future-proof architecture | • Team needs to learn new concepts<br>• Fewer ready-made solutions<br>• May need to build some features from scratch |
|
||||||
|
| Maintenance | • Less server infrastructure<br>• Simpler deployment<br>• Fewer points of failure | • Debugging can be more complex<br>• State management requires careful handling |
|
||||||
|
| Business Impact | • Better offline support for users<br>• Scales well as user base grows<br>• No licensing costs (with Yjs) | • Initial development time might be longer<br>• Team training required |
|
||||||
|
|
||||||
|
#### Yjs
|
||||||
|
- **Type**: State-based CRDT
|
||||||
|
- **Implementation**: JavaScript/TypeScript
|
||||||
|
- **Features**:
|
||||||
|
- Rich text collaboration
|
||||||
|
- Shared types (Array, Map, XML)
|
||||||
|
- Binary encoding
|
||||||
|
- P2P support
|
||||||
|
- **Performance**: Excellent for text editing
|
||||||
|
- **Memory Usage**: Optimized
|
||||||
|
- **License**: MIT
|
||||||
|
|
||||||
|
#### Automerge
|
||||||
|
- **Type**: Operation-based CRDT
|
||||||
|
- **Implementation**: JavaScript/Rust
|
||||||
|
- **Features**:
|
||||||
|
- JSON-like data structures
|
||||||
|
- Change history
|
||||||
|
- Undo/Redo
|
||||||
|
- Binary format
|
||||||
|
- **Performance**: Good, with Rust backend
|
||||||
|
- **Memory Usage**: Higher than Yjs
|
||||||
|
- **License**: MIT
|
||||||
|
|
||||||
|
#### Legion
|
||||||
|
- **Type**: State-based CRDT
|
||||||
|
- **Implementation**: Rust with JS bindings
|
||||||
|
- **Features**:
|
||||||
|
- High performance
|
||||||
|
- Memory efficient
|
||||||
|
- Binary protocol
|
||||||
|
- **Performance**: Excellent
|
||||||
|
- **Memory Usage**: Very efficient
|
||||||
|
- **License**: Apache 2.0
|
||||||
|
|
||||||
|
#### Diamond Types
|
||||||
|
- **Type**: Operation-based CRDT
|
||||||
|
- **Implementation**: TypeScript
|
||||||
|
- **Features**:
|
||||||
|
- Specialized for text
|
||||||
|
- Small memory footprint
|
||||||
|
- Simple API
|
||||||
|
- **Performance**: Good for text
|
||||||
|
- **Memory Usage**: Efficient
|
||||||
|
- **License**: MIT
|
||||||
|
|
||||||
|
Comparison Table:
|
||||||
|
|
||||||
|
| Feature | Yjs | Automerge | Legion | Diamond Types |
|
||||||
|
|---------|-----|-----------|--------|---------------|
|
||||||
|
| Text Editing | ✅ Excellent | ✅ Good | ⚠️ Basic | ✅ Excellent |
|
||||||
|
| Structured Data | ✅ | ✅ | ✅ | ⚠️ |
|
||||||
|
| Memory Efficiency | ✅ High | ⚠️ Medium | ✅ Very High | ✅ High |
|
||||||
|
| Network Efficiency | ✅ | ⚠️ | ✅ | ✅ |
|
||||||
|
| Maturity | ✅ | ✅ | ⚠️ | ⚠️ |
|
||||||
|
| Community Size | ✅ Large | ✅ Large | ⚠️ Small | ⚠️ Small |
|
||||||
|
| Documentation | ✅ | ✅ | ⚠️ | ⚠️ |
|
||||||
|
| Backend Options | ✅ Many | ✅ Many | ⚠️ Limited | ⚠️ Limited |
|
||||||
|
|
||||||
|
Key Differences:
|
||||||
|
1. **Implementation Approach**:
|
||||||
|
- Yjs: Optimized for text and rich-text editing
|
||||||
|
- Automerge: General-purpose JSON CRDT
|
||||||
|
- Legion: Performance-focused with Rust
|
||||||
|
- Diamond Types: Specialized for text collaboration
|
||||||
|
|
||||||
|
2. **Performance Characteristics**:
|
||||||
|
- Yjs: Best for text editing scenarios
|
||||||
|
- Automerge: Good all-around performance
|
||||||
|
- Legion: Excellent raw performance
|
||||||
|
- Diamond Types: Optimized for text
|
||||||
|
|
||||||
|
3. **Ecosystem Integration**:
|
||||||
|
- Yjs: Wide range of integrations
|
||||||
|
- Automerge: Good JavaScript ecosystem
|
||||||
|
- Legion: Limited but growing
|
||||||
|
- Diamond Types: Focused on text editors
|
||||||
|
|
||||||
|
This analysis reinforces our choice of Yjs for the CRDT-based option as it provides:
|
||||||
|
- Best-in-class text editing performance
|
||||||
|
- Mature ecosystem
|
||||||
|
- Active community
|
||||||
|
- Excellent documentation
|
||||||
|
- Wide range of backend options
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
After evaluating the alternatives, we choose Yjs for the following reasons:
|
||||||
|
|
||||||
|
1. **Technical Fit:**
|
||||||
|
- Native CRDT support ensures reliable collaboration
|
||||||
|
- Excellent offline capabilities
|
||||||
|
- Good performance characteristics
|
||||||
|
- Flexible backend integration options
|
||||||
|
|
||||||
|
2. **Project Requirements Match:**
|
||||||
|
- Easy integration with our Django backend
|
||||||
|
- Supports our core collaborative features
|
||||||
|
- Manageable learning curve for the team
|
||||||
|
|
||||||
|
3. **Community & Support:**
|
||||||
|
- Active development
|
||||||
|
- Growing community
|
||||||
|
- Good documentation
|
||||||
|
- Open source with MIT license
|
||||||
|
|
||||||
|
### Comparison of Key Features:
|
||||||
|
|
||||||
|
| Feature | Yjs (CRDT) | ProseMirror | ShareDB | Convergence |
|
||||||
|
|---------|-----|-------------|----------|-------------|
|
||||||
|
| Real-time Collaboration | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Offline Support | ✅ | ⚠️ | ⚠️ | ✅ |
|
||||||
|
| Django Integration | Easy | Complex | Complex | Moderate |
|
||||||
|
| Learning Curve | Medium | High | High | Medium |
|
||||||
|
| Cost | Free | Free | Free | Paid |
|
||||||
|
| Community Size | Growing | Large | Medium | Small |
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
- Simplified implementation of real-time collaboration
|
||||||
|
- Good developer experience
|
||||||
|
- Future-proof technology choice
|
||||||
|
- No licensing costs
|
||||||
|
|
||||||
|
### Negative
|
||||||
|
- Team needs to learn CRDT concepts
|
||||||
|
- Newer technology compared to alternatives
|
||||||
|
- May need to build some features available out-of-the-box in other solutions
|
||||||
|
|
||||||
|
### Risks
|
||||||
|
- Community support might not grow as expected
|
||||||
|
- May discover limitations as we scale
|
||||||
19
docs/architecture.md
Normal file
19
docs/architecture.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Global system architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
User -- HTTP --> Front("Frontend (NextJS SPA)")
|
||||||
|
Front -- REST API --> Back("Backend (Django)")
|
||||||
|
Front -- WebSocket --> Yserver("Microservice Yjs (Express)") -- WebSocket --> CollaborationServer("Collaboration server (Hocuspocus)") -- REST API <--> Back
|
||||||
|
Front -- OIDC --> Back -- OIDC ---> OIDC("Keycloak / ProConnect")
|
||||||
|
Back -- REST API --> Yserver
|
||||||
|
Back --> DB("Database (PostgreSQL)")
|
||||||
|
Back <--> Celery --> DB
|
||||||
|
Back ----> S3("Minio (S3)")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Architecture decision records
|
||||||
|
|
||||||
|
- [ADR-0001-20250106-use-yjs-for-docs-editing](./adr/ADR-0001-20250106-use-yjs-for-docs-editing.md)
|
||||||
BIN
docs/assets/banner-docs.png
Normal file
BIN
docs/assets/banner-docs.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 215 KiB |
BIN
docs/assets/footer-configurable.png
Normal file
BIN
docs/assets/footer-configurable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
145
docs/env.md
Normal file
145
docs/env.md
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
# Docs variables
|
||||||
|
|
||||||
|
Here we describe all environment variables that can be set for the docs application.
|
||||||
|
|
||||||
|
## impress-backend container
|
||||||
|
|
||||||
|
These are the environment variables you can set for the `impress-backend` container.
|
||||||
|
|
||||||
|
| Option | Description | default |
|
||||||
|
|-------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------|
|
||||||
|
| AI_ALLOW_REACH_FROM | Users that can use AI must be this level. options are "public", "authenticated", "restricted" | authenticated |
|
||||||
|
| AI_API_KEY | AI key to be used for AI Base url | |
|
||||||
|
| AI_BASE_URL | OpenAI compatible AI base url | |
|
||||||
|
| AI_FEATURE_ENABLED | Enable AI options | false |
|
||||||
|
| AI_MODEL | AI Model to use | |
|
||||||
|
| ALLOW_LOGOUT_GET_METHOD | Allow get logout method | true |
|
||||||
|
| API_USERS_LIST_LIMIT | Limit on API users | 5 |
|
||||||
|
| API_USERS_LIST_THROTTLE_RATE_BURST | Throttle rate for api on burst | 30/minute |
|
||||||
|
| API_USERS_LIST_THROTTLE_RATE_SUSTAINED | Throttle rate for api | 180/hour |
|
||||||
|
| AWS_S3_ACCESS_KEY_ID | Access id for s3 endpoint | |
|
||||||
|
| AWS_S3_ENDPOINT_URL | S3 endpoint | |
|
||||||
|
| AWS_S3_REGION_NAME | Region name for s3 endpoint | |
|
||||||
|
| AWS_S3_SECRET_ACCESS_KEY | Access key for s3 endpoint | |
|
||||||
|
| AWS_STORAGE_BUCKET_NAME | Bucket name for s3 endpoint | impress-media-storage |
|
||||||
|
| CACHES_DEFAULT_TIMEOUT | Cache default timeout | 30 |
|
||||||
|
| CACHES_KEY_PREFIX | The prefix used to every cache keys. | docs |
|
||||||
|
| COLLABORATION_API_URL | Collaboration api host | |
|
||||||
|
| COLLABORATION_SERVER_SECRET | Collaboration api secret | |
|
||||||
|
| COLLABORATION_WS_NOT_CONNECTED_READY_ONLY | Users not connected to the collaboration server cannot edit | false |
|
||||||
|
| COLLABORATION_WS_URL | Collaboration websocket url | |
|
||||||
|
| CONVERSION_API_CONTENT_FIELD | Conversion api content field | content |
|
||||||
|
| CONVERSION_API_ENDPOINT | Conversion API endpoint | convert |
|
||||||
|
| CONVERSION_API_SECURE | Require secure conversion api | false |
|
||||||
|
| CONVERSION_API_TIMEOUT | Conversion api timeout | 30 |
|
||||||
|
| CRISP_WEBSITE_ID | Crisp website id for support | |
|
||||||
|
| DB_ENGINE | Engine to use for database connections | django.db.backends.postgresql_psycopg2 |
|
||||||
|
| DB_HOST | Host of the database | localhost |
|
||||||
|
| DB_NAME | Name of the database | impress |
|
||||||
|
| DB_PASSWORD | Password to authenticate with | pass |
|
||||||
|
| DB_PORT | Port of the database | 5432 |
|
||||||
|
| DB_USER | User to authenticate with | dinum |
|
||||||
|
| DJANGO_ALLOWED_HOSTS | Allowed hosts | [] |
|
||||||
|
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker transport options | {} |
|
||||||
|
| DJANGO_CELERY_BROKER_URL | Celery broker url | redis://redis:6379/0 |
|
||||||
|
| DJANGO_CORS_ALLOW_ALL_ORIGINS | Allow all CORS origins | false |
|
||||||
|
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | List of origins allowed for CORS using regulair expressions | [] |
|
||||||
|
| DJANGO_CORS_ALLOWED_ORIGINS | List of origins allowed for CORS | [] |
|
||||||
|
| DJANGO_CSRF_TRUSTED_ORIGINS | CSRF trusted origins | [] |
|
||||||
|
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
|
||||||
|
| DJANGO_EMAIL_BRAND_NAME | Brand name for email | |
|
||||||
|
| DJANGO_EMAIL_FROM | Email address used as sender | from@example.com |
|
||||||
|
| DJANGO_EMAIL_HOST | Hostname of email | |
|
||||||
|
| DJANGO_EMAIL_HOST_PASSWORD | Password to authenticate with on the email host | |
|
||||||
|
| DJANGO_EMAIL_HOST_USER | User to authenticate with on the email host | |
|
||||||
|
| DJANGO_EMAIL_LOGO_IMG | Logo for the email | |
|
||||||
|
| DJANGO_EMAIL_PORT | Port used to connect to email host | |
|
||||||
|
| DJANGO_EMAIL_USE_SSL | Use ssl for email host connection | false |
|
||||||
|
| DJANGO_EMAIL_USE_TLS | Use tls for email host connection | false |
|
||||||
|
| DJANGO_SECRET_KEY | Secret key | |
|
||||||
|
| DJANGO_SERVER_TO_SERVER_API_TOKENS | | [] |
|
||||||
|
| DOCUMENT_IMAGE_MAX_SIZE | Maximum size of document in bytes | 10485760 |
|
||||||
|
| FRONTEND_CSS_URL | To add a external css file to the app | |
|
||||||
|
| FRONTEND_HOMEPAGE_FEATURE_ENABLED | Frontend feature flag to display the homepage | false |
|
||||||
|
| FRONTEND_THEME | Frontend theme to use | |
|
||||||
|
| LANGUAGE_CODE | Default language | en-us |
|
||||||
|
| LOGGING_LEVEL_LOGGERS_APP | Application logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
|
||||||
|
| LOGGING_LEVEL_LOGGERS_ROOT | Default logging level. options are "DEBUG", "INFO", "WARN", "ERROR", "CRITICAL" | INFO |
|
||||||
|
| LOGIN_REDIRECT_URL | Login redirect url | |
|
||||||
|
| LOGIN_REDIRECT_URL_FAILURE | Login redirect url on failure | |
|
||||||
|
| LOGOUT_REDIRECT_URL | Logout redirect url | |
|
||||||
|
| MALWARE_DETECTION_BACKEND | The malware detection backend use from the django-lasuite package | lasuite.malware_detection.backends.dummy.DummyBackend |
|
||||||
|
| MALWARE_DETECTION_PARAMETERS | A dict containing all the parameters to initiate the malware detection backend | {"callback_path": "core.malware_detection.malware_detection_callback",} |
|
||||||
|
| MEDIA_BASE_URL | | |
|
||||||
|
| NO_WEBSOCKET_CACHE_TIMEOUT | Cache used to store current editor session key when only users without websocket are editing a document | 120 |
|
||||||
|
| OIDC_ALLOW_DUPLICATE_EMAILS | Allow duplicate emails | false |
|
||||||
|
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | OIDC extra auth parameters | {} |
|
||||||
|
| OIDC_CREATE_USER | Create used on OIDC | false |
|
||||||
|
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification | true |
|
||||||
|
| OIDC_OP_AUTHORIZATION_ENDPOINT | Authorization endpoint for OIDC | |
|
||||||
|
| OIDC_OP_JWKS_ENDPOINT | JWKS endpoint for OIDC | |
|
||||||
|
| OIDC_OP_LOGOUT_ENDPOINT | Logout endpoint for OIDC | |
|
||||||
|
| OIDC_OP_TOKEN_ENDPOINT | Token endpoint for OIDC | |
|
||||||
|
| OIDC_OP_USER_ENDPOINT | User endpoint for OIDC | |
|
||||||
|
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed hosts for OIDC redirect url | [] |
|
||||||
|
| OIDC_REDIRECT_REQUIRE_HTTPS | Require https for OIDC redirect url | false |
|
||||||
|
| OIDC_RP_CLIENT_ID | Client id used for OIDC | impress |
|
||||||
|
| OIDC_RP_CLIENT_SECRET | Client secret used for OIDC | |
|
||||||
|
| OIDC_RP_SCOPES | Scopes requested for OIDC | openid email |
|
||||||
|
| OIDC_RP_SIGN_ALGO | verification algorithm used OIDC tokens | RS256 |
|
||||||
|
| OIDC_STORE_ID_TOKEN | Store OIDC token | true |
|
||||||
|
| OIDC_USE_NONCE | Use nonce for OIDC | true |
|
||||||
|
| OIDC_USERINFO_FULLNAME_FIELDS | OIDC token claims to create full name | ["first_name", "last_name"] |
|
||||||
|
| OIDC_USERINFO_SHORTNAME_FIELD | OIDC token claims to create shortname | first_name |
|
||||||
|
| POSTHOG_KEY | Posthog key for analytics | |
|
||||||
|
| REDIS_URL | Cache url | redis://redis:6379/1 |
|
||||||
|
| SENTRY_DSN | Sentry host | |
|
||||||
|
| SESSION_COOKIE_AGE | duration of the cookie session | 60*60*12 |
|
||||||
|
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | | false |
|
||||||
|
| STORAGES_STATICFILES_BACKEND | | whitenoise.storage.CompressedManifestStaticFilesStorage |
|
||||||
|
| THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 |
|
||||||
|
| THEME_CUSTOMIZATION_FILE_PATH | Full path to the file customizing the theme. An example is provided in src/backend/impress/configuration/theme/default.json | BASE_DIR/impress/configuration/theme/default.json |
|
||||||
|
| TRASHBIN_CUTOFF_DAYS | Trashbin cutoff | 30 |
|
||||||
|
| USER_OIDC_ESSENTIAL_CLAIMS | Essential claims in OIDC token | [] |
|
||||||
|
| Y_PROVIDER_API_BASE_URL | Y Provider url | |
|
||||||
|
| Y_PROVIDER_API_KEY | Y provider API key | |
|
||||||
|
|
||||||
|
|
||||||
|
## impress-frontend image
|
||||||
|
|
||||||
|
These are the environment variables you can set to build the `impress-frontend` image.
|
||||||
|
|
||||||
|
Depending on how you are building the front-end application, this variable is used in different ways.
|
||||||
|
|
||||||
|
If you want to build the Docker image, this variable is used as an argument in the build command.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker build -f src/frontend/Dockerfile --target frontend-production --build-arg PUBLISH_AS_MIT=false docs-frontend:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
If you want to build the front-end application using the yarn build command, you can edit the file `src/frontend/apps/impress/.env` with the `NODE_ENV=production` environment variable and modify it. Alternatively, you can use the listed environment variables with the prefix `NEXT_PUBLIC_` (for example, `NEXT_PUBLIC_PUBLISH_AS_MIT=false`).
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```
|
||||||
|
cd src/frontend/apps/impress
|
||||||
|
NODE_ENV=production NEXT_PUBLIC_PUBLISH_AS_MIT=false yarn build
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Description | default |
|
||||||
|
| ----------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
|
||||||
|
| API_ORIGIN | backend domain - it uses the current domain if not initialized | |
|
||||||
|
| SW_DEACTIVATED | To not install the service worker | |
|
||||||
|
| PUBLISH_AS_MIT | Removes packages whose licences are incompatible with the MIT licence (see below) | true |
|
||||||
|
|
||||||
|
Packages with licences incompatible with the MIT licence:
|
||||||
|
* `xl-docx-exporter`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-docx-exporter/LICENSE),
|
||||||
|
* `xl-pdf-exporter`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE),
|
||||||
|
* `xl-multi-column`: [GPL](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-multi-column/LICENSE).
|
||||||
|
|
||||||
|
In `.env.development`, `PUBLISH_AS_MIT` is set to `false`, allowing developers to test Docs with all its features.
|
||||||
|
|
||||||
|
⚠️ If you run Docs in production with `PUBLISH_AS_MIT` set to `false` make sure you fulfill your BlockNote licensing or [subscription](https://www.blocknotejs.org/about#partner-with-us) obligations.
|
||||||
|
|
||||||
78
docs/examples/compose/compose.yaml
Normal file
78
docs/examples/compose/compose.yaml
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
services:
|
||||||
|
postgresql:
|
||||||
|
image: postgres:16
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
|
||||||
|
interval: 1s
|
||||||
|
timeout: 2s
|
||||||
|
retries: 300
|
||||||
|
env_file:
|
||||||
|
- env.d/postgresql
|
||||||
|
- env.d/common
|
||||||
|
environment:
|
||||||
|
- PGDATA=/var/lib/postgresql/data/pgdata
|
||||||
|
volumes:
|
||||||
|
- ./data/databases/backend:/var/lib/postgresql/data/pgdata
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:8
|
||||||
|
|
||||||
|
backend:
|
||||||
|
image: lasuite/impress-backend:latest
|
||||||
|
user: ${DOCKER_USER:-1000}
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
- DJANGO_CONFIGURATION=Production
|
||||||
|
env_file:
|
||||||
|
- env.d/common
|
||||||
|
- env.d/backend
|
||||||
|
- env.d/yprovider
|
||||||
|
- env.d/postgresql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "manage.py", "check"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 30s
|
||||||
|
retries: 20
|
||||||
|
start_period: 10s
|
||||||
|
depends_on:
|
||||||
|
postgresql:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: true
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
|
y-provider:
|
||||||
|
image: lasuite/impress-y-provider:latest
|
||||||
|
user: ${DOCKER_USER:-1000}
|
||||||
|
env_file:
|
||||||
|
- env.d/common
|
||||||
|
- env.d/yprovider
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: lasuite/impress-frontend:latest
|
||||||
|
user: "101"
|
||||||
|
entrypoint:
|
||||||
|
- /docker-entrypoint.sh
|
||||||
|
command: ["nginx", "-g", "daemon off;"]
|
||||||
|
env_file:
|
||||||
|
- env.d/common
|
||||||
|
# Uncomment and set your values if using our nginx proxy example
|
||||||
|
#environment:
|
||||||
|
# - VIRTUAL_HOST=${DOCS_HOST} # used by nginx proxy
|
||||||
|
# - VIRTUAL_PORT=8083 # used by nginx proxy
|
||||||
|
# - LETSENCRYPT_HOST=${DOCS_HOST} # used by lets encrypt to generate TLS certificate
|
||||||
|
volumes:
|
||||||
|
- ./default.conf.template:/etc/nginx/templates/docs.conf.template
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
# Uncomment if using our nginx proxy example
|
||||||
|
# networks:
|
||||||
|
# - proxy-tier
|
||||||
|
# - default
|
||||||
|
|
||||||
|
# Uncomment if using our nginx proxy example
|
||||||
|
#networks:
|
||||||
|
# proxy-tier:
|
||||||
|
# external: true
|
||||||
88
docs/examples/compose/keycloak/README.md
Normal file
88
docs/examples/compose/keycloak/README.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# Deploy and Configure Keycloak for Docs
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
> \[!CAUTION\]
|
||||||
|
> We provide those instructions as an example, for production environments, you should follow the [official documentation](https://www.keycloak.org/documentation).
|
||||||
|
|
||||||
|
### Step 1: Prepare your working environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir keycloak
|
||||||
|
curl -o keycloak/compose.yaml https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/docs/examples/compose/keycloak/compose.yaml
|
||||||
|
curl -o keycloak/env.d/kc_postgresql https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/kc_postgresql
|
||||||
|
curl -o keycloak/env.d/keycloak https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/keycloak
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2:. Update `env.d/` files
|
||||||
|
|
||||||
|
The following variables need to be updated with your own values, others can be left as is:
|
||||||
|
|
||||||
|
```env
|
||||||
|
POSTGRES_PASSWORD=<generate postgres password>
|
||||||
|
KC_HOSTNAME=https://id.yourdomain.tld # Change with your own URL
|
||||||
|
KC_BOOTSTRAP_ADMIN_PASSWORD=<generate your password>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Expose keycloak instance on https
|
||||||
|
|
||||||
|
> \[!NOTE\]
|
||||||
|
> You can skip this section if you already have your own setup.
|
||||||
|
|
||||||
|
To access your Keycloak instance on the public network, it needs to be exposed on a domain with SSL termination. You can use our [example with nginx proxy and Let's Encrypt companion](../nginx-proxy/README.md) for automated creation/renewal of certificates using [acme.sh](http://acme.sh).
|
||||||
|
|
||||||
|
If following our example, uncomment the environment and network sections in compose file and update it with your values.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: '3'
|
||||||
|
services:
|
||||||
|
keycloak:
|
||||||
|
...
|
||||||
|
# Uncomment and set your values if using our nginx proxy example
|
||||||
|
# environment:
|
||||||
|
# - VIRTUAL_HOST=id.yourdomain.tld # used by nginx proxy
|
||||||
|
# - VIRTUAL_PORT=8080 # used by nginx proxy
|
||||||
|
# - LETSENCRYPT_HOST=id.yourdomain.tld # used by lets encrypt to generate TLS certificate
|
||||||
|
...
|
||||||
|
# Uncomment if using our nginx proxy example
|
||||||
|
# networks:
|
||||||
|
# - proxy-tier
|
||||||
|
# - default
|
||||||
|
|
||||||
|
# Uncomment if using our nginx proxy example
|
||||||
|
#networks:
|
||||||
|
# proxy-tier:
|
||||||
|
# external: true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Start the service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
`docker compose up -d`
|
||||||
|
```
|
||||||
|
|
||||||
|
Your keycloak instance is now available on https://doc.yourdomain.tld
|
||||||
|
|
||||||
|
## Creating an OIDC Client for Docs Application
|
||||||
|
|
||||||
|
### Step 1: Create a New Realm
|
||||||
|
|
||||||
|
1. Log in to the Keycloak administration console.
|
||||||
|
2. Navigate to the realm tab and click on the "Create realm" button.
|
||||||
|
3. Enter the name of the realm - `docs`.
|
||||||
|
4. Click "Create".
|
||||||
|
|
||||||
|
#### Step 2: Create a New Client
|
||||||
|
|
||||||
|
1. Navigate to the "Clients" tab.
|
||||||
|
2. Click on the "Create client" button.
|
||||||
|
3. Enter the client ID - e.g. `docs`.
|
||||||
|
4. Enable "Client authentication" option.
|
||||||
|
6. Set the "Valid redirect URIs" to the URL of your docs application suffixed with `/*` - e.g., "https://docs.example.com/*".
|
||||||
|
1. Set the "Web Origins" to the URL of your docs application - e.g. `https://docs.example.com`.
|
||||||
|
1. Click "Save".
|
||||||
|
|
||||||
|
#### Step 3: Get Client Credentials
|
||||||
|
|
||||||
|
1. Go to the "Credentials" tab.
|
||||||
|
2. Copy the client ID (`docs` in this example) and the client secret.
|
||||||
36
docs/examples/compose/keycloak/compose.yaml
Normal file
36
docs/examples/compose/keycloak/compose.yaml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
services:
|
||||||
|
kc_postgresql:
|
||||||
|
image: postgres:16
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
|
||||||
|
interval: 1s
|
||||||
|
timeout: 2s
|
||||||
|
retries: 300
|
||||||
|
env_file:
|
||||||
|
- env.d/kc_postgresql
|
||||||
|
volumes:
|
||||||
|
- ./data/keycloak:/var/lib/postgresql/data/pgdata
|
||||||
|
|
||||||
|
keycloak:
|
||||||
|
image: quay.io/keycloak/keycloak:26.1.3
|
||||||
|
command: ["start"]
|
||||||
|
env_file:
|
||||||
|
- env.d/kc_postgresql
|
||||||
|
- env.d/keycloak
|
||||||
|
# Uncomment and set your values if using our nginx proxy example
|
||||||
|
# environment:
|
||||||
|
# - VIRTUAL_HOST=id.yourdomain.tld # used by nginx proxy
|
||||||
|
# - VIRTUAL_PORT=8080 # used by nginx proxy
|
||||||
|
# - LETSENCRYPT_HOST=id.yourdomain.tld # used by lets encrypt to generate TLS certificate
|
||||||
|
depends_on:
|
||||||
|
kc_postgresql:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: true
|
||||||
|
# Uncomment if using our nginx proxy example
|
||||||
|
# networks:
|
||||||
|
# - proxy-tier
|
||||||
|
# - default
|
||||||
|
#
|
||||||
|
#networks:
|
||||||
|
# proxy-tier:
|
||||||
|
# external: true
|
||||||
103
docs/examples/compose/minio/README.md
Normal file
103
docs/examples/compose/minio/README.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# Deploy and Configure Minio for Docs
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
> \[!CAUTION\]
|
||||||
|
> We provide those instructions as an example, it should not be run in production. For production environments, deploy MinIO [in a Multi-Node Multi-Drive (Distributed)](https://min.io/docs/minio/linux/operations/install-deploy-manage/deploy-minio-multi-node-multi-drive.html#minio-mnmd) topology
|
||||||
|
|
||||||
|
### Step 1: Prepare your working environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir minio
|
||||||
|
curl -o minio/compose.yaml https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/docs/examples/compose/minio/compose.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2:. Update compose file with your own values
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: '3'
|
||||||
|
services:
|
||||||
|
minio:
|
||||||
|
...
|
||||||
|
environment:
|
||||||
|
- MINIO_ROOT_USER=<Set minio root username>
|
||||||
|
- MINIO_ROOT_PASSWORD=<Set minio root password>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Expose MinIO instance
|
||||||
|
|
||||||
|
#### Option 1: Internal network
|
||||||
|
|
||||||
|
You may not need to expose your MinIO instance to the public if only services hosted on the same private network need to access to your MinIO instance.
|
||||||
|
|
||||||
|
You should create a docker network that will be shared between those services
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker network create storage-tier
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Option 2: Public network
|
||||||
|
|
||||||
|
If you want to expose your MinIO instance to the public, it needs to be exposed on a domain with SSL termination. You can use our [example](../nginx-proxy/README.md) with an nginx proxy and Let's Encrypt companion for automated creation/renewal of Let's Encrypt certificates using [acme.sh](http://acme.sh).
|
||||||
|
|
||||||
|
If following our example, uncomment the environment and network sections in compose file and update it with your values.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: '3'
|
||||||
|
services:
|
||||||
|
docs:
|
||||||
|
...
|
||||||
|
minio:
|
||||||
|
...
|
||||||
|
environment:
|
||||||
|
...
|
||||||
|
# - VIRTUAL_HOST=storage.yourdomain.tld # used by nginx proxy
|
||||||
|
# - VIRTUAL_PORT=9000 # used by nginx proxy
|
||||||
|
# - LETSENCRYPT_HOST=storage.yourdomain.tld # used by lets encrypt to generate TLS certificate
|
||||||
|
...
|
||||||
|
# Uncomment if using our nginx proxy example
|
||||||
|
# networks:
|
||||||
|
# - proxy-tier
|
||||||
|
# - default
|
||||||
|
|
||||||
|
# Uncomment if using our nginx proxy example
|
||||||
|
#networks:
|
||||||
|
# proxy-tier:
|
||||||
|
# external: true
|
||||||
|
```
|
||||||
|
|
||||||
|
In this example we are only exposing MinIO API service. Follow the official documentation to configure Minio WebUI.
|
||||||
|
|
||||||
|
### Step 4: Start the service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
`docker compose up -d`
|
||||||
|
```
|
||||||
|
|
||||||
|
Your minio instance is now available on https://storage.yourdomain.tld
|
||||||
|
|
||||||
|
## Creating a user and bucket for your Docs instance
|
||||||
|
|
||||||
|
### Installing mc
|
||||||
|
|
||||||
|
Follow the [official documentation](https://min.io/docs/minio/linux/reference/minio-mc.html#install-mc) to install mc
|
||||||
|
|
||||||
|
### Step 1: Configure `mc` to connect to your MinIO Server with your root user
|
||||||
|
|
||||||
|
```shellscript
|
||||||
|
mc alias set minio <MINIO_SERVER_URL> <MINIO_ROOT_USER> <MINIO_ROOT_PASSWORD>
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the values with those you have set in the previous steps
|
||||||
|
|
||||||
|
### Step 2: Create a new bucket with versioning enabled
|
||||||
|
|
||||||
|
```shellscript
|
||||||
|
mc mb --with-versioning minio/<your-bucket-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `your-bucket-name` with the desired name for your bucket e.g. `docs-media-storage`
|
||||||
|
|
||||||
|
### Additional notes:
|
||||||
|
|
||||||
|
For increased security you should create a dedicated user with `readwrite` access to the Bucket. In the following example we will use MinIO root user.
|
||||||
27
docs/examples/compose/minio/compose.yaml
Normal file
27
docs/examples/compose/minio/compose.yaml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
services:
|
||||||
|
minio:
|
||||||
|
image: minio/minio
|
||||||
|
environment:
|
||||||
|
- MINIO_ROOT_USER=<set minio root username>
|
||||||
|
- MINIO_ROOT_PASSWORD=<set minio root password>
|
||||||
|
# Uncomment and set your values if using our nginx proxy example
|
||||||
|
# - VIRTUAL_HOST=storage.yourdomain.tld # used by nginx proxy
|
||||||
|
# - VIRTUAL_PORT=9000 # used by nginx proxy
|
||||||
|
# - LETSENCRYPT_HOST=storage.yourdomain.tld # used by lets encrypt to generate TLS certificate
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mc", "ready", "local"]
|
||||||
|
interval: 1s
|
||||||
|
timeout: 20s
|
||||||
|
retries: 300
|
||||||
|
entrypoint: ""
|
||||||
|
command: minio server /data
|
||||||
|
volumes:
|
||||||
|
- ./data/minio:/data
|
||||||
|
# Uncomment if using our nginx proxy example
|
||||||
|
# networks:
|
||||||
|
# - proxy-tier
|
||||||
|
|
||||||
|
# Uncomment if using our nginx proxy example
|
||||||
|
#networks:
|
||||||
|
# proxy-tier:
|
||||||
|
# external: true
|
||||||
39
docs/examples/compose/nginx-proxy/README.md
Normal file
39
docs/examples/compose/nginx-proxy/README.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Nginx proxy with automatic SSL certificates
|
||||||
|
|
||||||
|
> \[!CAUTION\]
|
||||||
|
> We provide those instructions as an example, for extended development or production environments, you should follow the [official documentation](https://github.com/nginx-proxy/acme-companion/tree/main/docs).
|
||||||
|
|
||||||
|
Nginx-proxy sets up a container running nginx and docker-gen. docker-gen generates reverse proxy configs for nginx and reloads nginx when containers are started and stopped.
|
||||||
|
|
||||||
|
Acme-companion is a lightweight companion container for nginx-proxy. It handles the automated creation, renewal and use of SSL certificates for proxied Docker containers through the ACME protocol.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Step 1: Prepare your working environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir nginx-proxy
|
||||||
|
curl -o nginx-proxy/compose.yaml https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/docs/examples/compose/nginx-proxy/compose.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Edit `DEFAULT_EMAIL` in the compose file.
|
||||||
|
|
||||||
|
Albeit optional, it is recommended to provide a valid default email address through the `DEFAULT_EMAIL` environment variable, so that Let's Encrypt can warn you about expiring certificates and allow you to recover your account.
|
||||||
|
|
||||||
|
### Step 3: Create docker network
|
||||||
|
|
||||||
|
Containers need share the same network for auto-discovery.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker network create proxy-tier
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Start service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Once both nginx-proxy and acme-companion containers are up and running, start any container you want proxied with environment variables `VIRTUAL_HOST` and `LETSENCRYPT_HOST` both set to the domain(s) your proxied container is going to use.
|
||||||
36
docs/examples/compose/nginx-proxy/compose.yaml
Normal file
36
docs/examples/compose/nginx-proxy/compose.yaml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
services:
|
||||||
|
nginx-proxy:
|
||||||
|
image: nginxproxy/nginx-proxy
|
||||||
|
container_name: nginx-proxy
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- html:/usr/share/nginx/html
|
||||||
|
- certs:/etc/nginx/certs:ro
|
||||||
|
- /var/run/docker.sock:/tmp/docker.sock:ro
|
||||||
|
networks:
|
||||||
|
- proxy-tier
|
||||||
|
|
||||||
|
acme-companion:
|
||||||
|
image: nginxproxy/acme-companion
|
||||||
|
container_name: nginx-proxy-acme
|
||||||
|
environment:
|
||||||
|
- DEFAULT_EMAIL=mail@yourdomain.tld
|
||||||
|
volumes_from:
|
||||||
|
- nginx-proxy
|
||||||
|
volumes:
|
||||||
|
- certs:/etc/nginx/certs:rw
|
||||||
|
- acme:/etc/acme.sh
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
networks:
|
||||||
|
- proxy-tier
|
||||||
|
|
||||||
|
networks:
|
||||||
|
proxy-tier:
|
||||||
|
external: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
html:
|
||||||
|
certs:
|
||||||
|
acme:
|
||||||
@@ -27,14 +27,14 @@ backend:
|
|||||||
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth
|
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth
|
||||||
OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token
|
OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token
|
||||||
OIDC_OP_USER_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/userinfo
|
OIDC_OP_USER_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/userinfo
|
||||||
OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/session/end
|
OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/logout
|
||||||
OIDC_RP_CLIENT_ID: impress
|
OIDC_RP_CLIENT_ID: impress
|
||||||
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
|
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
|
||||||
OIDC_RP_SIGN_ALGO: RS256
|
OIDC_RP_SIGN_ALGO: RS256
|
||||||
OIDC_RP_SCOPES: "openid email"
|
OIDC_RP_SCOPES: "openid email"
|
||||||
OIDC_VERIFY_SSL: False
|
OIDC_VERIFY_SSL: False
|
||||||
USER_OIDC_FIELD_TO_SHORTNAME: "given_name"
|
OIDC_USERINFO_SHORTNAME_FIELD: "given_name"
|
||||||
USER_OIDC_FIELDS_TO_FULLNAME: "given_name,usual_name"
|
OIDC_USERINFO_FULLNAME_FIELDS: "given_name,usual_name"
|
||||||
OIDC_REDIRECT_ALLOWED_HOSTS: https://impress.127.0.0.1.nip.io
|
OIDC_REDIRECT_ALLOWED_HOSTS: https://impress.127.0.0.1.nip.io
|
||||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}"
|
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}"
|
||||||
LOGIN_REDIRECT_URL: https://impress.127.0.0.1.nip.io
|
LOGIN_REDIRECT_URL: https://impress.127.0.0.1.nip.io
|
||||||
@@ -46,9 +46,6 @@ backend:
|
|||||||
DB_USER: dinum
|
DB_USER: dinum
|
||||||
DB_PASSWORD: pass
|
DB_PASSWORD: pass
|
||||||
DB_PORT: 5432
|
DB_PORT: 5432
|
||||||
POSTGRES_DB: impress
|
|
||||||
POSTGRES_USER: dinum
|
|
||||||
POSTGRES_PASSWORD: pass
|
|
||||||
REDIS_URL: redis://default:pass@redis-master:6379/1
|
REDIS_URL: redis://default:pass@redis-master:6379/1
|
||||||
AWS_S3_ENDPOINT_URL: http://minio.impress.svc.cluster.local:9000
|
AWS_S3_ENDPOINT_URL: http://minio.impress.svc.cluster.local:9000
|
||||||
AWS_S3_ACCESS_KEY_ID: root
|
AWS_S3_ACCESS_KEY_ID: root
|
||||||
@@ -82,13 +79,13 @@ backend:
|
|||||||
python manage.py createsuperuser --email admin@example.com --password admin
|
python manage.py createsuperuser --email admin@example.com --password admin
|
||||||
restartPolicy: Never
|
restartPolicy: Never
|
||||||
|
|
||||||
# Exra volume to manage our local custom CA and avoid to set ssl_verify: false
|
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
|
||||||
extraVolumeMounts:
|
extraVolumeMounts:
|
||||||
- name: certs
|
- name: certs
|
||||||
mountPath: /usr/local/lib/python3.12/site-packages/certifi/cacert.pem
|
mountPath: /cert/cacert.pem
|
||||||
subPath: cacert.pem
|
subPath: cacert.pem
|
||||||
|
|
||||||
# Exra volume to manage our local custom CA and avoid to set ssl_verify: false
|
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
|
||||||
extraVolumes:
|
extraVolumes:
|
||||||
- name: certs
|
- name: certs
|
||||||
configMap:
|
configMap:
|
||||||
@@ -121,6 +118,22 @@ yProvider:
|
|||||||
COLLABORATION_SERVER_ORIGIN: https://impress.127.0.0.1.nip.io
|
COLLABORATION_SERVER_ORIGIN: https://impress.127.0.0.1.nip.io
|
||||||
COLLABORATION_SERVER_SECRET: my-secret
|
COLLABORATION_SERVER_SECRET: my-secret
|
||||||
Y_PROVIDER_API_KEY: my-secret
|
Y_PROVIDER_API_KEY: my-secret
|
||||||
|
COLLABORATION_BACKEND_BASE_URL: https://impress.127.0.0.1.nip.io
|
||||||
|
NODE_EXTRA_CA_CERTS: /usr/local/share/ca-certificates/cacert.pem
|
||||||
|
|
||||||
|
# Mount the certificate so yProvider can establish tls with the backend
|
||||||
|
extraVolumeMounts:
|
||||||
|
- name: certs
|
||||||
|
mountPath: /usr/local/share/ca-certificates/cacert.pem
|
||||||
|
subPath: cacert.pem
|
||||||
|
|
||||||
|
extraVolumes:
|
||||||
|
- name: certs
|
||||||
|
configMap:
|
||||||
|
name: certifi
|
||||||
|
items:
|
||||||
|
- key: cacert.pem
|
||||||
|
path: cacert.pem
|
||||||
|
|
||||||
posthog:
|
posthog:
|
||||||
ingress:
|
ingress:
|
||||||
@@ -135,9 +148,6 @@ ingress:
|
|||||||
ingressCollaborationWS:
|
ingressCollaborationWS:
|
||||||
enabled: true
|
enabled: true
|
||||||
host: impress.127.0.0.1.nip.io
|
host: impress.127.0.0.1.nip.io
|
||||||
|
|
||||||
annotations:
|
|
||||||
nginx.ingress.kubernetes.io/auth-url: https://impress.127.0.0.1.nip.io/api/v1.0/documents/collaboration-auth/
|
|
||||||
|
|
||||||
ingressCollaborationApi:
|
ingressCollaborationApi:
|
||||||
enabled: true
|
enabled: true
|
||||||
@@ -91,7 +91,7 @@ extraDeploy:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "user-e2e-chromium",
|
"username": "user-e2e-chromium",
|
||||||
"email": "user@chromium.e2e",
|
"email": "user@chromium.test",
|
||||||
"firstName": "E2E",
|
"firstName": "E2E",
|
||||||
"lastName": "Chromium",
|
"lastName": "Chromium",
|
||||||
"enabled": "true",
|
"enabled": "true",
|
||||||
@@ -105,7 +105,7 @@ extraDeploy:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "user-e2e-webkit",
|
"username": "user-e2e-webkit",
|
||||||
"email": "user@webkit.e2e",
|
"email": "user@webkit.test",
|
||||||
"firstName": "E2E",
|
"firstName": "E2E",
|
||||||
"lastName": "Webkit",
|
"lastName": "Webkit",
|
||||||
"enabled": "true",
|
"enabled": "true",
|
||||||
@@ -119,7 +119,7 @@ extraDeploy:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "user-e2e-firefox",
|
"username": "user-e2e-firefox",
|
||||||
"email": "user@firefox.e2e",
|
"email": "user@firefox.test",
|
||||||
"firstName": "E2E",
|
"firstName": "E2E",
|
||||||
"lastName": "Firefox",
|
"lastName": "Firefox",
|
||||||
"enabled": "true",
|
"enabled": "true",
|
||||||
32
docs/installation/README.md
Normal file
32
docs/installation/README.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Installation
|
||||||
|
If you want to install Docs you've come to the right place.
|
||||||
|
Here are a bunch of resources to help you install the project.
|
||||||
|
|
||||||
|
## Kubernetes
|
||||||
|
We (Docs maintainers) are only using the Kubernetes deployment method in production. We can only provide advanced support for this method.
|
||||||
|
Please follow the instructions laid out [here](/docs/installation/kubernetes.md).
|
||||||
|
|
||||||
|
## Docker Compose
|
||||||
|
We are aware that not everyone has Kubernetes Cluster laying around 😆.
|
||||||
|
We also provide [Docker images](https://hub.docker.com/u/lasuite?page=1&search=impress) that you can deploy using Compose.
|
||||||
|
Please follow the instructions [here](/docs/installation/compose.md).
|
||||||
|
⚠️ Please keep in mind that we do not use it ourselves in production. Let us know in the issues if you run into troubles, we'll try to help.
|
||||||
|
|
||||||
|
## Other ways to install Docs
|
||||||
|
Community members have contributed several other ways to install Docs. While we owe them a big thanks 🙏, please keep in mind we (Docs maintainers) can't provide support on these installation methods as we don't use them ourselves and there are two many options out there for us to keep track of. Of course you can contact the contributors and the broader community for assistance.
|
||||||
|
|
||||||
|
Here is the list of other methods in alphabetical order:
|
||||||
|
- Coop-Cloud: [code](https://git.coopcloud.tech/coop-cloud/lasuite-docs)
|
||||||
|
- Nix: [Packages](https://search.nixos.org/packages?channel=unstable&query=lasuite-docs), ⚠️ unstable
|
||||||
|
- Podman: [code][https://codeberg.org/philo/lasuite-docs-podman], ⚠️ experimental
|
||||||
|
- YunoHost: [code](https://github.com/YunoHost-Apps/lasuite-docs_ynh), [app store](https://apps.yunohost.org/app/lasuite-docs)
|
||||||
|
|
||||||
|
Feel free to make a PR to add ones that are not listed above 🙏
|
||||||
|
|
||||||
|
## Cloud providers
|
||||||
|
Some cloud providers are making it easy to deploy Docs on their infrastructure.
|
||||||
|
|
||||||
|
Here is the list in alphabetical order:
|
||||||
|
- Clever Cloud 🇫🇷 : [market place][https://www.clever-cloud.com/product/docs/], [technical doc](https://www.clever.cloud/developers/guides/docs/#deploy-docs)
|
||||||
|
|
||||||
|
Feel free to make a PR to add ones that are not listed above 🙏
|
||||||
232
docs/installation/compose.md
Normal file
232
docs/installation/compose.md
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
# Installation with docker compose
|
||||||
|
|
||||||
|
We provide a sample configuration for running Docs using Docker Compose. Please note that this configuration is experimental, and the official way to deploy Docs in production is to use [k8s](../installation/kubernetes.md)
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- A modern version of Docker and its Compose plugin.
|
||||||
|
- A domain name and DNS configured to your server.
|
||||||
|
- An Identity Provider that supports OpenID Connect protocol - we provide [an example to deploy Keycloak](../examples/compose/keycloak/README.md).
|
||||||
|
- An Object Storage that implements S3 API - we provide [an example to deploy Minio](../examples/compose/minio/README.md).
|
||||||
|
- A Postgresql database - we provide [an example in the compose file](../examples/compose/compose.yaml).
|
||||||
|
- A Redis database - we provide [an example in the compose file](../examples/compose/compose.yaml).
|
||||||
|
|
||||||
|
## Software Requirements
|
||||||
|
|
||||||
|
Ensure you have Docker Compose(v2) installed on your host server. Follow the official guidelines for a reliable setup:
|
||||||
|
|
||||||
|
Docker Compose is included with Docker Engine:
|
||||||
|
|
||||||
|
- **Docker Engine:** We suggest adhering to the instructions provided by Docker
|
||||||
|
for [installing Docker Engine](https://docs.docker.com/engine/install/).
|
||||||
|
|
||||||
|
For older versions of Docker Engine that do not include Docker Compose:
|
||||||
|
|
||||||
|
- **Docker Compose:** Install it as per the [official documentation](https://docs.docker.com/compose/install/).
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> `docker-compose` may not be supported. You are advised to use `docker compose` instead.
|
||||||
|
|
||||||
|
## Step 1: Prepare your working environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p docs/env.d
|
||||||
|
cd docs
|
||||||
|
curl -o compose.yaml https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/docs/examples/compose/compose.yaml
|
||||||
|
curl -o env.d/common https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/common
|
||||||
|
curl -o env.d/backend https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/backend
|
||||||
|
curl -o env.d/yprovider https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/yprovider
|
||||||
|
curl -o env.d/postgresql https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/env.d/production.dist/postgresql
|
||||||
|
```
|
||||||
|
|
||||||
|
If you are using the sample nginx-proxy configuration:
|
||||||
|
```bash
|
||||||
|
curl -o default.conf.template https://raw.githubusercontent.com/suitenumerique/docs/refs/heads/main/docker/files/production/etc/nginx/conf.d/default.conf.template
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 2: Configuration
|
||||||
|
|
||||||
|
Docs configuration is achieved through environment variables. We provide a [detailed description of all variables](../env.md).
|
||||||
|
|
||||||
|
In this example, we assume the following services:
|
||||||
|
|
||||||
|
- OIDC provider on https://id.yourdomain.tld
|
||||||
|
- Object Storage on https://storage.yourdomain.tld
|
||||||
|
- Docs on https://docs.yourdomain.tld
|
||||||
|
- Bucket name is docs-media-storage
|
||||||
|
|
||||||
|
**Set your own values in `env.d/common`**
|
||||||
|
|
||||||
|
### OIDC
|
||||||
|
|
||||||
|
Authentication in Docs is managed through Open ID Connect protocol. A functional Identity Provider implementing this protocol is required.
|
||||||
|
|
||||||
|
For guidance, refer to our [Keycloak deployment example](../examples/compose/keycloak/README.md).
|
||||||
|
|
||||||
|
If using Keycloak as your Identity Provider, set `OIDC_RP_CLIENT_ID` and `OIDC_RP_CLIENT_SECRET` variables with those of the OIDC client created for Docs. By default we have set `docs` as the realm name, if you have named your realm differently, update the value `REALM_NAME` in `env.d/common`
|
||||||
|
|
||||||
|
For others OIDC providers, update the variables in `env.d/backend`.
|
||||||
|
|
||||||
|
### Object Storage
|
||||||
|
|
||||||
|
Files and media are stored in an Object Store that supports the S3 API.
|
||||||
|
|
||||||
|
For guidance, refer to our [Minio deployment example](../examples/compose/minio/README.md).
|
||||||
|
|
||||||
|
Set `AWS_S3_ACCESS_KEY_ID` and `AWS_S3_SECRET_ACCESS_KEY` with the credentials of a user with `readwrite` access to the bucket created for Docs.
|
||||||
|
|
||||||
|
### Postgresql
|
||||||
|
|
||||||
|
Docs uses PostgreSQL as its database. Although an external PostgreSQL can be used, our example provides a deployment method.
|
||||||
|
|
||||||
|
If you are using the example provided, you need to generate a secure key for `DB_PASSWORD` and set it in `env.d/postgresql`.
|
||||||
|
|
||||||
|
If you are using an external service or not using our default values, you should update the variables in `env.d/postgresql`
|
||||||
|
|
||||||
|
### Redis
|
||||||
|
|
||||||
|
Docs uses Redis for caching. While an external Redis can be used, our example provides a deployment method.
|
||||||
|
|
||||||
|
If you are using an external service, you need to set `REDIS_URL` environment variable in `env.d/backend`.
|
||||||
|
|
||||||
|
### Y Provider
|
||||||
|
|
||||||
|
The Y provider service enables collaboration through websockets.
|
||||||
|
|
||||||
|
Generates a secure key for `Y_PROVIDER_API_KEY` and `COLLABORATION_SERVER_SECRET` in ``env.d/yprovider``.
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
|
||||||
|
The Docs backend is built on the Django Framework.
|
||||||
|
|
||||||
|
Generates a secure key for `DJANGO_SECRET_KEY` in `env.d/backend`.
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
Update the following variables in `env.d/backend` if you want to change the logging levels:
|
||||||
|
```env
|
||||||
|
LOGGING_LEVEL_HANDLERS_CONSOLE=DEBUG
|
||||||
|
LOGGING_LEVEL_LOGGERS_ROOT=DEBUG
|
||||||
|
LOGGING_LEVEL_LOGGERS_APP=DEBUG
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mail
|
||||||
|
|
||||||
|
The following environment variables are required in `env.d/backend` for the mail service to send invitations :
|
||||||
|
|
||||||
|
```env
|
||||||
|
DJANGO_EMAIL_HOST=<smtp host>
|
||||||
|
DJANGO_EMAIL_HOST_USER=<smtp user>
|
||||||
|
DJANGO_EMAIL_HOST_PASSWORD=<smtp password>
|
||||||
|
DJANGO_EMAIL_PORT=<smtp port>
|
||||||
|
DJANGO_EMAIL_FROM=<your email address>
|
||||||
|
|
||||||
|
#DJANGO_EMAIL_USE_TLS=true # A flag to enable or disable TLS for email sending.
|
||||||
|
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
|
||||||
|
|
||||||
|
|
||||||
|
DJANGO_EMAIL_BRAND_NAME=<brand name used in email templates> # e.g. "La Suite Numérique"
|
||||||
|
DJANGO_EMAIL_LOGO_IMG=<logo image to use in email templates.> # e.g. "https://docs.yourdomain.tld/assets/logo-suite-numerique.png"
|
||||||
|
```
|
||||||
|
|
||||||
|
### AI
|
||||||
|
|
||||||
|
Built-in AI actions let users generate, summarize, translate, and correct content.
|
||||||
|
|
||||||
|
AI is disabled by default. To enable it, the following environment variables must be set in in `env.d/backend`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
AI_FEATURE_ENABLED=true # is false by default
|
||||||
|
AI_BASE_URL=https://openaiendpoint.com
|
||||||
|
AI_API_KEY=<API key>
|
||||||
|
AI_MODEL=<model used> e.g. llama
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend theme
|
||||||
|
|
||||||
|
You can [customize your Docs instance](../theming.md) with your own theme and custom css.
|
||||||
|
|
||||||
|
The following environment variables must be set in `env.d/backend`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
FRONTEND_THEME=default # name of your theme built with cuningham
|
||||||
|
FRONTEND_CSS_URL=https://storage.yourdomain.tld/themes/custom.css # custom css
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 3: Reverse proxy and SSL/TLS
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> In a production environment, configure SSL/TLS termination to run your instance on https.
|
||||||
|
|
||||||
|
If you have your own certificates and proxy setup, you can skip this part.
|
||||||
|
|
||||||
|
You can follow our [nginx proxy example](../examples/compose/nginx-proxy/README.md) with automatic generation and renewal of certificate with Let's Encrypt.
|
||||||
|
|
||||||
|
You will need to uncomment the environment and network sections in compose file and update it with your values.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
frontend:
|
||||||
|
...
|
||||||
|
# Uncomment and set your values if using our nginx proxy example
|
||||||
|
#environment:
|
||||||
|
# - VIRTUAL_HOST=${DOCS_HOST} # used by nginx proxy
|
||||||
|
# - VIRTUAL_PORT=8083 # used by nginx proxy
|
||||||
|
# - LETSENCRYPT_HOST=${DOCS_HOST} # used by lets encrypt to generate TLS certificate
|
||||||
|
...
|
||||||
|
# Uncomment if using our nginx proxy example
|
||||||
|
# networks:
|
||||||
|
# - proxy-tier
|
||||||
|
#
|
||||||
|
#networks:
|
||||||
|
# proxy-tier:
|
||||||
|
# external: true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 4: Start Docs
|
||||||
|
|
||||||
|
You are ready to start your Docs application !
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
> [!NOTE]
|
||||||
|
> Version of the images are set to latest, you should pin it to the desired version to avoid unwanted upgrades when pulling latest image.
|
||||||
|
|
||||||
|
## Step 5: Run the database migration and create Django admin user
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose run --rm backend python manage.py migrate
|
||||||
|
docker compose run --rm backend python manage.py createsuperuser --email <admin email> --password <admin password>
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `<admin email>` with the email of your admin user and generate a secure password.
|
||||||
|
|
||||||
|
Your docs instance is now available on the domain you defined, https://docs.yourdomain.tld.
|
||||||
|
|
||||||
|
THe admin interface is available on https://docs.yourdomain.tld/admin with the admin user you just created.
|
||||||
|
|
||||||
|
## How to upgrade your Docs application
|
||||||
|
|
||||||
|
Before running an upgrade you must check the [Upgrade document](../../UPGRADE.md) for specific procedures that might be needed.
|
||||||
|
|
||||||
|
You can also check the [Changelog](../../CHANGELOG.md) for brief summary of the changes.
|
||||||
|
|
||||||
|
### Step 1: Edit the images tag with the desired version
|
||||||
|
|
||||||
|
### Step 2: Pull the images
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose pull
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Restart your containers
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose restart
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Run the database migration
|
||||||
|
Your database schema may need to be updated, run:
|
||||||
|
```bash
|
||||||
|
docker compose run --rm backend python manage.py migrate
|
||||||
|
```
|
||||||
@@ -1,21 +1,20 @@
|
|||||||
# Installation on a k8s cluster
|
# Installation on a k8s cluster
|
||||||
|
|
||||||
This document is a step-by-step guide that describes how to install Docs on a k8s cluster without AI features. It's a teaching document to learn how it's work. It needs to be adapt for production environment.
|
This document is a step-by-step guide that describes how to install Docs on a k8s cluster without AI features. It's a teaching document to learn how it works. It needs to be adapted for a production environment.
|
||||||
|
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- k8s cluster with an nginx-ingress controller
|
- k8s cluster with an nginx-ingress controller
|
||||||
- an OIDC provider (if you don't have one, we will provide an example)
|
- an OIDC provider (if you don't have one, we provide an example)
|
||||||
- a PostgreSQL server (if you don't have one, we will provide an example)
|
- a PostgreSQL server (if you don't have one, we provide an example)
|
||||||
- a Memcached server (if you don't have one, we will provide an example)
|
- a Memcached server (if you don't have one, we provide an example)
|
||||||
- a S3 bucket (if you don't have one, we will provide an example)
|
- a S3 bucket (if you don't have one, we provide an example)
|
||||||
|
|
||||||
### Test cluster
|
### Test cluster
|
||||||
|
|
||||||
If you do not have a test cluster, you can install everything on a local kind cluster. In this case, the simplest way is to use our script **bin/start-kind.sh**.
|
If you do not have a test cluster, you can install everything on a local Kind cluster. In this case, the simplest way is to use our script **bin/start-kind.sh**.
|
||||||
|
|
||||||
To be able to use the script, you will need to install:
|
To be able to use the script, you need to install:
|
||||||
|
|
||||||
- Docker (https://docs.docker.com/desktop/)
|
- Docker (https://docs.docker.com/desktop/)
|
||||||
- Kind (https://kind.sigs.k8s.io/docs/user/quick-start/#installation)
|
- Kind (https://kind.sigs.k8s.io/docs/user/quick-start/#installation)
|
||||||
@@ -23,7 +22,7 @@ To be able to use the script, you will need to install:
|
|||||||
- Helm (https://helm.sh/docs/intro/quickstart/#install-helm)
|
- Helm (https://helm.sh/docs/intro/quickstart/#install-helm)
|
||||||
|
|
||||||
```
|
```
|
||||||
./bin/start-kind.sh
|
./bin/start-kind.sh
|
||||||
% Total % Received % Xferd Average Speed Time Time Time Current
|
% Total % Received % Xferd Average Speed Time Time Time Current
|
||||||
Dload Upload Total Spent Left Speed
|
Dload Upload Total Spent Left Speed
|
||||||
100 4700 100 4700 0 0 92867 0 --:--:-- --:--:-- --:--:-- 94000
|
100 4700 100 4700 0 0 92867 0 --:--:-- --:--:-- --:--:-- 94000
|
||||||
@@ -46,11 +45,11 @@ It will expire on 24 March 2027 🗓
|
|||||||
2. Create kind cluster with containerd registry config dir enabled
|
2. Create kind cluster with containerd registry config dir enabled
|
||||||
Creating cluster "suite" ...
|
Creating cluster "suite" ...
|
||||||
✓ Ensuring node image (kindest/node:v1.27.3) 🖼
|
✓ Ensuring node image (kindest/node:v1.27.3) 🖼
|
||||||
✓ Preparing nodes 📦
|
✓ Preparing nodes 📦
|
||||||
✓ Writing configuration 📜
|
✓ Writing configuration 📜
|
||||||
✓ Starting control-plane 🕹️
|
✓ Starting control-plane 🕹️
|
||||||
✓ Installing CNI 🔌
|
✓ Installing CNI 🔌
|
||||||
✓ Installing StorageClass 💾
|
✓ Installing StorageClass 💾
|
||||||
Set kubectl context to "kind-suite"
|
Set kubectl context to "kind-suite"
|
||||||
You can now use your cluster with:
|
You can now use your cluster with:
|
||||||
|
|
||||||
@@ -96,13 +95,14 @@ ingress-nginx-admission-create-t55ph 0/1 Completed 0 2m56s
|
|||||||
ingress-nginx-admission-patch-94dvt 0/1 Completed 1 2m56s
|
ingress-nginx-admission-patch-94dvt 0/1 Completed 1 2m56s
|
||||||
ingress-nginx-controller-57c548c4cd-2rx47 1/1 Running 0 2m56s
|
ingress-nginx-controller-57c548c4cd-2rx47 1/1 Running 0 2m56s
|
||||||
```
|
```
|
||||||
When your k8s cluster is ready (the ingress nginx controller is up), you can start the deployment. This cluster is special because it uses the *.127.0.0.1.nip.io domain and mkcert certificates to have full HTTPS support and easy domain name management.
|
|
||||||
|
|
||||||
Please remember that *.127.0.0.1.nip.io will always resolve to 127.0.0.1, except in the k8s cluster where we configure CoreDNS to answer with the ingress-nginx service IP.
|
When your k8s cluster is ready (the ingress nginx controller is up), you can start the deployment. This cluster is special because it uses the `*.127.0.0.1.nip.io` domain and mkcert certificates to have full HTTPS support and easy domain name management.
|
||||||
|
|
||||||
|
Please remember that `*.127.0.0.1.nip.io` will always resolve to `127.0.0.1`, except in the k8s cluster where we configure CoreDNS to answer with the ingress-nginx service IP.
|
||||||
|
|
||||||
## Preparation
|
## Preparation
|
||||||
|
|
||||||
### What will you use to authenticate your users ?
|
### What do you use to authenticate your users?
|
||||||
|
|
||||||
Docs uses OIDC, so if you already have an OIDC provider, obtain the necessary information to use it. In the next step, we will see how to configure Django (and thus Docs) to use it. If you do not have a provider, we will show you how to deploy a local Keycloak instance (this is not a production deployment, just a demo).
|
Docs uses OIDC, so if you already have an OIDC provider, obtain the necessary information to use it. In the next step, we will see how to configure Django (and thus Docs) to use it. If you do not have a provider, we will show you how to deploy a local Keycloak instance (this is not a production deployment, just a demo).
|
||||||
|
|
||||||
@@ -117,14 +117,14 @@ keycloak-0 1/1 Running 0 6m48s
|
|||||||
keycloak-postgresql-0 1/1 Running 0 6m48s
|
keycloak-postgresql-0 1/1 Running 0 6m48s
|
||||||
```
|
```
|
||||||
|
|
||||||
From here the important informations you will need are :
|
From here the important information you will need are:
|
||||||
|
|
||||||
```
|
```yaml
|
||||||
OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/certs
|
OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/certs
|
||||||
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth
|
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth
|
||||||
OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token
|
OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token
|
||||||
OIDC_OP_USER_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/userinfo
|
OIDC_OP_USER_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/userinfo
|
||||||
OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/session/end
|
OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/logout
|
||||||
OIDC_RP_CLIENT_ID: impress
|
OIDC_RP_CLIENT_ID: impress
|
||||||
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
|
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
|
||||||
OIDC_RP_SIGN_ALGO: RS256
|
OIDC_RP_SIGN_ALGO: RS256
|
||||||
@@ -133,9 +133,9 @@ OIDC_RP_SCOPES: "openid email"
|
|||||||
|
|
||||||
You can find these values in **examples/keycloak.values.yaml**
|
You can find these values in **examples/keycloak.values.yaml**
|
||||||
|
|
||||||
### Find redis server connexion values
|
### Find redis server connection values
|
||||||
|
|
||||||
Impress need a redis so we will start by deploying a redis :
|
Docs needs a redis so we start by deploying one:
|
||||||
|
|
||||||
```
|
```
|
||||||
$ helm install redis oci://registry-1.docker.io/bitnamicharts/redis -f examples/redis.values.yaml
|
$ helm install redis oci://registry-1.docker.io/bitnamicharts/redis -f examples/redis.values.yaml
|
||||||
@@ -146,9 +146,9 @@ keycloak-postgresql-0 1/1 Running 0 26m
|
|||||||
redis-master-0 1/1 Running 0 35s
|
redis-master-0 1/1 Running 0 35s
|
||||||
```
|
```
|
||||||
|
|
||||||
### Find postgresql connexion values
|
### Find postgresql connection values
|
||||||
|
|
||||||
Impress uses a postgresql db as backend so if you have a provider, obtain the necessary information to use it. If you do not have, you can install a postgresql testing environment as follow:
|
Docs uses a postgresql database as backend, so if you have a provider, obtain the necessary information to use it. If you don't, you can install a postgresql testing environment as follow:
|
||||||
|
|
||||||
```
|
```
|
||||||
$ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/postgresql.values.yaml
|
$ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/postgresql.values.yaml
|
||||||
@@ -160,22 +160,19 @@ postgresql-0 1/1 Running 0 14m
|
|||||||
redis-master-0 1/1 Running 0 42s
|
redis-master-0 1/1 Running 0 42s
|
||||||
```
|
```
|
||||||
|
|
||||||
From here important informations you will need are :
|
From here the important information you will need are:
|
||||||
|
|
||||||
```
|
```yaml
|
||||||
DB_HOST: postgres-postgresql
|
DB_HOST: postgres-postgresql
|
||||||
DB_NAME: impress
|
DB_NAME: impress
|
||||||
DB_USER: dinum
|
DB_USER: dinum
|
||||||
DB_PASSWORD: pass
|
DB_PASSWORD: pass
|
||||||
DB_PORT: 5432
|
DB_PORT: 5432
|
||||||
POSTGRES_DB: impress
|
|
||||||
POSTGRES_USER: dinum
|
|
||||||
POSTGRES_PASSWORD: pass
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Find s3 bucket connexion values
|
### Find s3 bucket connection values
|
||||||
|
|
||||||
Impress uses a s3 bucket to store documents so if you have a provider obtain the necessary information to use it. If you do not have, you can install a local minio testing environment as follow:
|
Docs uses an s3 bucket to store documents, so if you have a provider obtain the necessary information to use it. If you don't, you can install a local minio testing environment as follow:
|
||||||
|
|
||||||
```
|
```
|
||||||
$ helm install minio oci://registry-1.docker.io/bitnamicharts/minio -f examples/minio.values.yaml
|
$ helm install minio oci://registry-1.docker.io/bitnamicharts/minio -f examples/minio.values.yaml
|
||||||
@@ -191,7 +188,7 @@ redis-master-0 1/1 Running 0 10m
|
|||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
Now you are ready to deploy Impress without AI. AI requiered more dependancies (openai API). To deploy impress you need to provide all previous informations to the helm chart.
|
Now you are ready to deploy Docs without AI. AI requires more dependencies (OpenAI API). To deploy Docs you need to provide all previous information to the helm chart.
|
||||||
|
|
||||||
```
|
```
|
||||||
$ helm repo add impress https://suitenumerique.github.io/docs/
|
$ helm repo add impress https://suitenumerique.github.io/docs/
|
||||||
@@ -214,7 +211,7 @@ redis-master-0 1/1 Running 0 20m
|
|||||||
|
|
||||||
## Test your deployment
|
## Test your deployment
|
||||||
|
|
||||||
In order to test your deployment you have to login to your instance. If you use exclusively our examples you can do :
|
In order to test your deployment you have to log into your instance. If you exclusively use our examples you can do:
|
||||||
|
|
||||||
```
|
```
|
||||||
$ kubectl get ingress
|
$ kubectl get ingress
|
||||||
@@ -227,5 +224,4 @@ impress-docs-ws <none> impress.127.0.0.1.nip.io localhost
|
|||||||
keycloak <none> keycloak.127.0.0.1.nip.io localhost 80 49m
|
keycloak <none> keycloak.127.0.0.1.nip.io localhost 80 49m
|
||||||
```
|
```
|
||||||
|
|
||||||
You can use impress on https://impress.127.0.0.1.nip.io. The provisionning user in keycloak is impress/impress.
|
You can use Docs at https://impress.127.0.0.1.nip.io. The provisionning user in keycloak is impress/impress.
|
||||||
|
|
||||||
110
docs/system-requirements.md
Normal file
110
docs/system-requirements.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# La Suite Docs – System & Requirements (2025-06)
|
||||||
|
|
||||||
|
## 1. Quick-Reference Matrix (single VM / laptop)
|
||||||
|
|
||||||
|
| Scenario | RAM | vCPU | SSD | Notes |
|
||||||
|
| ------------------------- | ----- | ---- | ------- | ------------------------- |
|
||||||
|
| **Solo dev** | 8 GB | 4 | 15 GB | Hot-reload + one IDE |
|
||||||
|
| **Team QA** | 16 GB | 6 | 30 GB | Runs integration tests |
|
||||||
|
| **Prod ≤ 100 live users** | 32 GB | 8 + | 50 GB + | Scale linearly above this |
|
||||||
|
|
||||||
|
Memory is the first bottleneck; CPU matters only when Celery or the Next.js build is saturated.
|
||||||
|
|
||||||
|
> **Note:** Memory consumption varies by operating system. Windows tends to be more memory-hungry than Linux, so consider adding 10-20% extra RAM when running on Windows compared to Linux-based systems.
|
||||||
|
|
||||||
|
## 2. Development Environment Memory Requirements
|
||||||
|
|
||||||
|
| Service | Typical use | Rationale / source |
|
||||||
|
| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------- |
|
||||||
|
| PostgreSQL | **1 – 2 GB** | `shared_buffers` starting point ≈ 25% RAM ([postgresql.org][1]) |
|
||||||
|
| Keycloak | **≈ 1.3 GB** | 70% of limit for heap + ~300 MB non-heap ([keycloak.org][2]) |
|
||||||
|
| Redis | **≤ 256 MB** | Empty instance ≈ 3 MB; budget 256 MB to allow small datasets ([stackoverflow.com][3]) |
|
||||||
|
| MinIO | **2 GB (dev) / 32 GB (prod)**| Pre-allocates 1–2 GiB; docs recommend 32 GB per host for ≤ 100 Ti storage ([min.io][4]) |
|
||||||
|
| Django API (+ Celery) | **0.8 – 1.5 GB** | Empirical in-house metrics |
|
||||||
|
| Next.js frontend | **0.5 – 1 GB** | Dev build chain |
|
||||||
|
| Y-Provider (y-websocket) | **< 200 MB** | Large 40 MB YDoc called “big” in community thread ([discuss.yjs.dev][5]) |
|
||||||
|
| Nginx | **< 100 MB** | Static reverse-proxy footprint |
|
||||||
|
|
||||||
|
[1]: https://www.postgresql.org/docs/9.1/runtime-config-resource.html "PostgreSQL: Documentation: 9.1: Resource Consumption"
|
||||||
|
[2]: https://www.keycloak.org/high-availability/concepts-memory-and-cpu-sizing "Concepts for sizing CPU and memory resources - Keycloak"
|
||||||
|
[3]: https://stackoverflow.com/questions/45233052/memory-footprint-for-redis-empty-instance "Memory footprint for Redis empty instance - Stack Overflow"
|
||||||
|
[4]: https://min.io/docs/minio/kubernetes/upstream/operations/checklists/hardware.html "Hardware Checklist — MinIO Object Storage for Kubernetes"
|
||||||
|
[5]: https://discuss.yjs.dev/t/understanding-memory-requirements-for-production-usage/198 "Understanding memory requirements for production usage - Yjs Community"
|
||||||
|
|
||||||
|
> **Rule of thumb:** add 2 GB for OS/overhead, then sum only the rows you actually run.
|
||||||
|
|
||||||
|
## 3. Production Environment Memory Requirements
|
||||||
|
|
||||||
|
Production deployments differ significantly from development environments. The table below shows typical memory usage for production services:
|
||||||
|
|
||||||
|
| Service | Typical use | Rationale / notes |
|
||||||
|
| ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------- |
|
||||||
|
| PostgreSQL | **2 – 8 GB** | Higher `shared_buffers` and connection pooling for concurrent users |
|
||||||
|
| OIDC Provider (optional) | **Variable** | Any OIDC-compatible provider (Keycloak, Auth0, Azure AD, etc.) - external or self-hosted |
|
||||||
|
| Redis | **256 MB – 2 GB** | Session storage and caching; scales with active user sessions |
|
||||||
|
| Object Storage (optional)| **External or self-hosted** | Can use AWS S3, Azure Blob, Google Cloud Storage, or self-hosted MinIO |
|
||||||
|
| Django API (+ Celery) | **1 – 3 GB** | Production workloads with background tasks and higher concurrency |
|
||||||
|
| Static Files (Nginx) | **< 200 MB** | Serves Next.js build output and static assets; no development overhead |
|
||||||
|
| Y-Provider (y-websocket) | **200 MB – 1 GB** | Scales with concurrent document editing sessions |
|
||||||
|
| Nginx (Load Balancer) | **< 200 MB** | Reverse proxy, SSL termination, static file serving |
|
||||||
|
|
||||||
|
### Production Architecture Notes
|
||||||
|
|
||||||
|
- **Frontend**: Uses pre-built Next.js static assets served by Nginx (no Node.js runtime needed)
|
||||||
|
- **Authentication**: Any OIDC-compatible provider can be used instead of self-hosted Keycloak
|
||||||
|
- **Object Storage**: External services (S3, Azure Blob) or self-hosted solutions (MinIO) are both viable
|
||||||
|
- **Database**: Consider PostgreSQL clustering or managed database services for high availability
|
||||||
|
- **Scaling**: Horizontal scaling is recommended for Django API and Y-Provider services
|
||||||
|
|
||||||
|
### Minimal Production Setup (Core Services Only)
|
||||||
|
|
||||||
|
| Service | Memory | Notes |
|
||||||
|
| ------------------------ | --------- | --------------------------------------- |
|
||||||
|
| PostgreSQL | **2 GB** | Core database |
|
||||||
|
| Django API (+ Celery) | **1.5 GB**| Backend services |
|
||||||
|
| Y-Provider | **200 MB**| Real-time collaboration |
|
||||||
|
| Nginx | **100 MB**| Static files + reverse proxy |
|
||||||
|
| Redis | **256 MB**| Session storage |
|
||||||
|
| **Total (without auth/storage)** | **≈ 4 GB** | External OIDC + object storage assumed |
|
||||||
|
|
||||||
|
## 4. Recommended Software Versions
|
||||||
|
|
||||||
|
| Tool | Minimum |
|
||||||
|
| ----------------------- | ------- |
|
||||||
|
| Docker Engine / Desktop | 24.0 |
|
||||||
|
| Docker Compose | v2 |
|
||||||
|
| Git | 2.40 |
|
||||||
|
| **Node.js** | 22+ |
|
||||||
|
| **Python** | 3.13+ |
|
||||||
|
| GNU Make | 4.4 |
|
||||||
|
| Kind | 0.22 |
|
||||||
|
| Helm | 3.14 |
|
||||||
|
| kubectl | 1.29 |
|
||||||
|
| mkcert | 1.4 |
|
||||||
|
|
||||||
|
|
||||||
|
## 5. Ports (dev defaults)
|
||||||
|
|
||||||
|
| Port | Service |
|
||||||
|
| --------- | --------------------- |
|
||||||
|
| 3000 | Next.js |
|
||||||
|
| 8071 | Django |
|
||||||
|
| 4444 | Y-Provider |
|
||||||
|
| 8080 | Keycloak |
|
||||||
|
| 8083 | Nginx proxy |
|
||||||
|
| 9000/9001 | MinIO |
|
||||||
|
| 15432 | PostgreSQL (main) |
|
||||||
|
| 5433 | PostgreSQL (Keycloak) |
|
||||||
|
| 1081 | MailCatcher |
|
||||||
|
|
||||||
|
## 6. Sizing Guidelines
|
||||||
|
|
||||||
|
**RAM** – start at 8 GB dev / 16 GB staging / 32 GB prod. Postgres and Keycloak are the first to OOM; scale them first.
|
||||||
|
|
||||||
|
> **OS considerations:** Windows systems typically require 10-20% more RAM than Linux due to higher OS overhead. Docker Desktop on Windows also uses additional memory compared to native Linux Docker.
|
||||||
|
|
||||||
|
**CPU** – budget one vCPU per busy container until Celery or Next.js builds saturate.
|
||||||
|
|
||||||
|
**Disk** – SSD; add 10 GB extra for the Docker layer cache.
|
||||||
|
|
||||||
|
**MinIO** – for demos, mount a local folder instead of running MinIO to save 2 GB+ of RAM.
|
||||||
88
docs/theming.md
Normal file
88
docs/theming.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# Runtime Theming 🎨
|
||||||
|
|
||||||
|
### How to Use
|
||||||
|
|
||||||
|
To use this feature, simply set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. For example:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
FRONTEND_CSS_URL=http://anything/custom-style.css
|
||||||
|
```
|
||||||
|
|
||||||
|
Once you've set this variable, our application will load your custom CSS file and apply the styles to our frontend application.
|
||||||
|
|
||||||
|
### Benefits
|
||||||
|
|
||||||
|
This feature provides several benefits, including:
|
||||||
|
|
||||||
|
* **Easy customization** 🔄: With this feature, you can easily customize the look and feel of our application without requiring any code changes.
|
||||||
|
* **Flexibility** 🌈: You can use any CSS styles you like to create a custom theme that meets your needs.
|
||||||
|
* **Runtime theming** ⏱️: This feature allows you to change the theme of our application at runtime, without requiring a restart or recompilation.
|
||||||
|
|
||||||
|
### Example Use Case
|
||||||
|
|
||||||
|
Let's say you want to change the background color of our application to a custom color. You can create a custom CSS file with the following contents:
|
||||||
|
|
||||||
|
```css
|
||||||
|
body {
|
||||||
|
background-color: #3498db;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. Once you've done this, our application will load your custom CSS file and apply the styles, changing the background color to the custom color you specified.
|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
|
# **Your Docs icon** 📝
|
||||||
|
|
||||||
|
You can add your own Docs icon in the header from the theme customization file.
|
||||||
|
|
||||||
|
### Settings 🔧
|
||||||
|
|
||||||
|
```shellscript
|
||||||
|
THEME_CUSTOMIZATION_FILE_PATH=<path>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example of JSON
|
||||||
|
|
||||||
|
You can activate it with the `header.icon` configuration: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
|
||||||
|
|
||||||
|
This configuration is optional. If not set, the default icon will be used.
|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
|
# **Footer Configuration** 📝
|
||||||
|
|
||||||
|
The footer is configurable from the theme customization file.
|
||||||
|
|
||||||
|
### Settings 🔧
|
||||||
|
|
||||||
|
```shellscript
|
||||||
|
THEME_CUSTOMIZATION_FILE_PATH=<path>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example of JSON
|
||||||
|
|
||||||
|
The json must follow some rules: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
|
||||||
|
|
||||||
|
`footer.default` is the fallback if the language is not supported.
|
||||||
|
|
||||||
|
---
|
||||||
|
Below is a visual example of a configured footer ⬇️:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
|
# **Custom Translations** 📝
|
||||||
|
|
||||||
|
The translations can be partially overridden from the theme customization file.
|
||||||
|
|
||||||
|
### Settings 🔧
|
||||||
|
|
||||||
|
```shellscript
|
||||||
|
THEME_CUSTOMIZATION_FILE_PATH=<path>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example of JSON
|
||||||
|
|
||||||
|
The json must follow some rules: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json
|
||||||
145
docs/troubleshoot.md
Normal file
145
docs/troubleshoot.md
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
# Troubleshooting Guide
|
||||||
|
|
||||||
|
## Line Ending Issues on Windows (LF/CRLF)
|
||||||
|
|
||||||
|
### Problem Description
|
||||||
|
|
||||||
|
This project uses **LF (Line Feed: `\n`) line endings** exclusively. Windows users may encounter issues because:
|
||||||
|
|
||||||
|
- **Windows** defaults to CRLF (Carriage Return + Line Feed: `\r\n`) for line endings
|
||||||
|
- **This project** uses LF line endings for consistency across all platforms
|
||||||
|
- **Git** may automatically convert line endings, causing conflicts or build failures
|
||||||
|
|
||||||
|
### Common Symptoms
|
||||||
|
|
||||||
|
- Git shows files as modified even when no changes were made
|
||||||
|
- Error messages like "warning: LF will be replaced by CRLF"
|
||||||
|
- Build failures or linting errors due to line ending mismatches
|
||||||
|
|
||||||
|
### Solutions for Windows Users
|
||||||
|
|
||||||
|
#### Configure Git to Preserve LF (Recommended)
|
||||||
|
|
||||||
|
Configure Git to NOT convert line endings and preserve LF:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git config core.autocrlf false
|
||||||
|
git config core.eol lf
|
||||||
|
```
|
||||||
|
|
||||||
|
This tells Git to:
|
||||||
|
- Never convert line endings automatically
|
||||||
|
- Always use LF for line endings in working directory
|
||||||
|
|
||||||
|
|
||||||
|
#### Fix Existing Repository with Wrong Line Endings
|
||||||
|
|
||||||
|
If you already have CRLF line endings in your local repository, the **best approach** is to configure Git properly and clone the project again:
|
||||||
|
|
||||||
|
1. **Configure Git first**:
|
||||||
|
```bash
|
||||||
|
git config --global core.autocrlf false
|
||||||
|
git config --global core.eol lf
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Clone the project fresh** (recommended):
|
||||||
|
```bash
|
||||||
|
# Navigate to parent directory
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# Remove current repository (backup your changes first!)
|
||||||
|
rm -rf docs
|
||||||
|
|
||||||
|
# Clone again with correct line endings
|
||||||
|
git clone git@github.com:suitenumerique/docs.git
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alternative**: If you have uncommitted changes and cannot re-clone:
|
||||||
|
|
||||||
|
1. **Backup your changes**:
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
git commit -m "Save changes before fixing line endings"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Remove all files from Git's index**:
|
||||||
|
```bash
|
||||||
|
git rm --cached -r .
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Reset Git configuration** (if not done globally):
|
||||||
|
```bash
|
||||||
|
git config core.autocrlf false
|
||||||
|
git config core.eol lf
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Re-add all files** (Git will use LF line endings):
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Commit the changes**:
|
||||||
|
```bash
|
||||||
|
git commit -m "✏️(project) Fix line endings to LF"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend File Watching Issues on Windows
|
||||||
|
|
||||||
|
### Problem Description
|
||||||
|
|
||||||
|
Windows users may experience issues with file watching in the frontend-development container. This typically happens because:
|
||||||
|
|
||||||
|
- **Docker on Windows** has known limitations with file change detection
|
||||||
|
- **Node.js file watchers** may not detect changes properly on Windows filesystem
|
||||||
|
- **Hot reloading** fails to trigger when files are modified
|
||||||
|
|
||||||
|
### Common Symptoms
|
||||||
|
|
||||||
|
- Changes to frontend code aren't detected automatically
|
||||||
|
- Hot module replacement doesn't work as expected
|
||||||
|
- Need to manually restart the frontend container after code changes
|
||||||
|
- Console shows no reaction when saving files
|
||||||
|
|
||||||
|
### Solution: Enable WATCHPACK_POLLING
|
||||||
|
|
||||||
|
Add the `WATCHPACK_POLLING=true` environment variable to the frontend-development service in your local environment:
|
||||||
|
|
||||||
|
1. **Modify the `compose.yml` file** by adding the environment variable to the frontend-development service:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
frontend-development:
|
||||||
|
user: "${DOCKER_USER:-1000}"
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: ./src/frontend/Dockerfile
|
||||||
|
target: impress-dev
|
||||||
|
args:
|
||||||
|
API_ORIGIN: "http://localhost:8071"
|
||||||
|
PUBLISH_AS_MIT: "false"
|
||||||
|
SW_DEACTIVATED: "true"
|
||||||
|
image: impress:frontend-development
|
||||||
|
environment:
|
||||||
|
- WATCHPACK_POLLING=true # Add this line for Windows users
|
||||||
|
volumes:
|
||||||
|
- ./src/frontend:/home/frontend
|
||||||
|
- /home/frontend/node_modules
|
||||||
|
- /home/frontend/apps/impress/node_modules
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Restart your containers**:
|
||||||
|
```bash
|
||||||
|
make run
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why This Works
|
||||||
|
|
||||||
|
- `WATCHPACK_POLLING=true` forces the file watcher to use polling instead of filesystem events
|
||||||
|
- Polling periodically checks for file changes rather than relying on OS-level file events
|
||||||
|
- This is more reliable on Windows but slightly increases CPU usage
|
||||||
|
- Changes to your frontend code should now be detected properly, enabling hot reloading
|
||||||
|
|
||||||
|
### Note
|
||||||
|
|
||||||
|
This setting is primarily needed for Windows users. Linux and macOS users typically don't need this setting as file watching works correctly by default on those platforms.
|
||||||
@@ -50,15 +50,22 @@ OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"]
|
|||||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
||||||
|
|
||||||
# AI
|
# AI
|
||||||
|
AI_FEATURE_ENABLED=true
|
||||||
AI_BASE_URL=https://openaiendpoint.com
|
AI_BASE_URL=https://openaiendpoint.com
|
||||||
AI_API_KEY=password
|
AI_API_KEY=password
|
||||||
AI_MODEL=llama
|
AI_MODEL=llama
|
||||||
|
|
||||||
# Collaboration
|
# Collaboration
|
||||||
COLLABORATION_API_URL=http://nginx:8083/collaboration/api/
|
COLLABORATION_API_URL=http://y-provider-development:4444/collaboration/api/
|
||||||
|
COLLABORATION_BACKEND_BASE_URL=http://app-dev:8000
|
||||||
COLLABORATION_SERVER_ORIGIN=http://localhost:3000
|
COLLABORATION_SERVER_ORIGIN=http://localhost:3000
|
||||||
COLLABORATION_SERVER_SECRET=my-secret
|
COLLABORATION_SERVER_SECRET=my-secret
|
||||||
COLLABORATION_WS_URL=ws://localhost:8083/collaboration/ws/
|
COLLABORATION_WS_NOT_CONNECTED_READY_ONLY=true
|
||||||
|
COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/
|
||||||
|
|
||||||
# Frontend
|
DJANGO_SERVER_TO_SERVER_API_TOKENS=server-api-token
|
||||||
FRONTEND_THEME=default
|
Y_PROVIDER_API_BASE_URL=http://y-provider-development:4444/api/
|
||||||
|
Y_PROVIDER_API_KEY=yprovider-api-key
|
||||||
|
|
||||||
|
# Theme customization
|
||||||
|
THEME_CUSTOMIZATION_CACHE_TIMEOUT=15
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
# For the CI job test-e2e
|
# For the CI job test-e2e
|
||||||
SUSTAINED_THROTTLE_RATES="200/hour"
|
|
||||||
BURST_THROTTLE_RATES="200/minute"
|
BURST_THROTTLE_RATES="200/minute"
|
||||||
DJANGO_SERVER_TO_SERVER_API_TOKENS=test-e2e
|
COLLABORATION_API_URL=http://y-provider:4444/collaboration/api/
|
||||||
Y_PROVIDER_API_KEY=yprovider-api-key
|
SUSTAINED_THROTTLE_RATES="200/hour"
|
||||||
Y_PROVIDER_API_BASE_URL=http://y-provider:4444/api/
|
Y_PROVIDER_API_BASE_URL=http://y-provider:4444/api/
|
||||||
|
|
||||||
|
# Throttle
|
||||||
|
API_DOCUMENT_THROTTLE_RATE=1000/min
|
||||||
|
API_CONFIG_THROTTLE_RATE=1000/min
|
||||||
65
env.d/production.dist/backend
Normal file
65
env.d/production.dist/backend
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
## Django
|
||||||
|
DJANGO_ALLOWED_HOSTS=${DOCS_HOST}
|
||||||
|
DJANGO_SECRET_KEY=<generate a random key>
|
||||||
|
DJANGO_SETTINGS_MODULE=impress.settings
|
||||||
|
DJANGO_CONFIGURATION=Production
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
# Set to DEBUG level for dev only
|
||||||
|
LOGGING_LEVEL_HANDLERS_CONSOLE=ERROR
|
||||||
|
LOGGING_LEVEL_LOGGERS_ROOT=INFO
|
||||||
|
LOGGING_LEVEL_LOGGERS_APP=INFO
|
||||||
|
|
||||||
|
# Python
|
||||||
|
PYTHONPATH=/app
|
||||||
|
|
||||||
|
# Mail
|
||||||
|
DJANGO_EMAIL_HOST=<smtp host>
|
||||||
|
DJANGO_EMAIL_HOST_USER=<smtp user>
|
||||||
|
DJANGO_EMAIL_HOST_PASSWORD=<smtp password>
|
||||||
|
DJANGO_EMAIL_PORT=<smtp port>
|
||||||
|
DJANGO_EMAIL_FROM=<your email address>
|
||||||
|
|
||||||
|
#DJANGO_EMAIL_USE_TLS=true # A flag to enable or disable TLS for email sending.
|
||||||
|
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
|
||||||
|
|
||||||
|
DJANGO_EMAIL_BRAND_NAME="La Suite Numérique"
|
||||||
|
DJANGO_EMAIL_LOGO_IMG="https://${DOCS_HOST}/assets/logo-suite-numerique.png"
|
||||||
|
|
||||||
|
# Media
|
||||||
|
AWS_S3_ENDPOINT_URL=https://${S3_HOST}
|
||||||
|
AWS_S3_ACCESS_KEY_ID=<s3 access key>
|
||||||
|
AWS_S3_SECRET_ACCESS_KEY=<s3 secret key>
|
||||||
|
AWS_STORAGE_BUCKET_NAME=${BUCKET_NAME}
|
||||||
|
MEDIA_BASE_URL=https://${DOCS_HOST}
|
||||||
|
|
||||||
|
# OIDC
|
||||||
|
OIDC_OP_JWKS_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/certs
|
||||||
|
OIDC_OP_AUTHORIZATION_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/auth
|
||||||
|
OIDC_OP_TOKEN_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/token
|
||||||
|
OIDC_OP_USER_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/userinfo
|
||||||
|
OIDC_OP_LOGOUT_ENDPOINT=https://${KEYCLOAK_HOST}/realms/${REALM_NAME}/protocol/openid-connect/logout
|
||||||
|
OIDC_RP_CLIENT_ID=<client_id>
|
||||||
|
OIDC_RP_CLIENT_SECRET=<client secret>
|
||||||
|
OIDC_RP_SIGN_ALGO=RS256
|
||||||
|
OIDC_RP_SCOPES="openid email"
|
||||||
|
#OIDC_USERINFO_SHORTNAME_FIELD
|
||||||
|
#OIDC_USERINFO_FULLNAME_FIELDS
|
||||||
|
|
||||||
|
LOGIN_REDIRECT_URL=https://${DOCS_HOST}
|
||||||
|
LOGIN_REDIRECT_URL_FAILURE=https://${DOCS_HOST}
|
||||||
|
LOGOUT_REDIRECT_URL=https://${DOCS_HOST}
|
||||||
|
|
||||||
|
OIDC_REDIRECT_ALLOWED_HOSTS=["https://${DOCS_HOST}"]
|
||||||
|
|
||||||
|
# AI
|
||||||
|
#AI_FEATURE_ENABLED=true # is false by default
|
||||||
|
#AI_BASE_URL=https://openaiendpoint.com
|
||||||
|
#AI_API_KEY=<API key>
|
||||||
|
#AI_MODEL=<model used> e.g. llama
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
#FRONTEND_THEME=mytheme
|
||||||
|
#FRONTEND_CSS_URL=https://storage.yourdomain.tld/themes/custom.css
|
||||||
|
#FRONTEND_FOOTER_FEATURE_ENABLED=true
|
||||||
|
#FRONTEND_URL_JSON_FOOTER=https://docs.domain.tld/contents/footer-demo.json
|
||||||
9
env.d/production.dist/common
Normal file
9
env.d/production.dist/common
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
DOCS_HOST=docs.domain.tld
|
||||||
|
KEYCLOAK_HOST=id.domain.tld
|
||||||
|
S3_HOST=storage.domain.tld
|
||||||
|
BACKEND_HOST=backend
|
||||||
|
FRONTEND_HOST=frontend
|
||||||
|
YPROVIDER_HOST=y-provider
|
||||||
|
BUCKET_NAME=docs-media-storage
|
||||||
|
REALM_NAME=docs
|
||||||
|
#COLLABORATION_WS_URL=wss://${DOCS_HOST}/collaboration/ws/
|
||||||
13
env.d/production.dist/kc_postgresql
Normal file
13
env.d/production.dist/kc_postgresql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Postgresql db container configuration
|
||||||
|
POSTGRES_DB=keycloak
|
||||||
|
POSTGRES_USER=keycloak
|
||||||
|
POSTGRES_PASSWORD=<generate postgres password>
|
||||||
|
PGDATA=/var/lib/postgresql/data/pgdata
|
||||||
|
|
||||||
|
# Keycloak postgresql configuration
|
||||||
|
KC_DB=postgres
|
||||||
|
KC_DB_SCHEMA=public
|
||||||
|
KC_DB_HOST=postgresql
|
||||||
|
KC_DB_NAME=${POSTGRES_DB}
|
||||||
|
KC_DB_USER=${POSTGRES_USER}
|
||||||
|
KC_DB_PASSWORD=${POSTGRES_PASSWORD}
|
||||||
8
env.d/production.dist/keycloak
Normal file
8
env.d/production.dist/keycloak
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Keycloak admin user
|
||||||
|
KC_BOOTSTRAP_ADMIN_USERNAME=admin
|
||||||
|
KC_BOOTSTRAP_ADMIN_PASSWORD=<generate your password>
|
||||||
|
|
||||||
|
# Keycloak configuration
|
||||||
|
KC_HOSTNAME=https://id.yourdomain.tld # Change with your own URL
|
||||||
|
KC_PROXY_HEADERS=xforwarded # in this example we are running behind an nginx proxy
|
||||||
|
KC_HTTP_ENABLED=true # in this example we are running behind an nginx proxy
|
||||||
11
env.d/production.dist/postgresql
Normal file
11
env.d/production.dist/postgresql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# App database configuration
|
||||||
|
DB_HOST=postgresql
|
||||||
|
DB_NAME=docs
|
||||||
|
DB_USER=docs
|
||||||
|
DB_PASSWORD=<generate a secure password>
|
||||||
|
DB_PORT=5432
|
||||||
|
|
||||||
|
# Postgresql db container configuration
|
||||||
|
POSTGRES_DB=docs
|
||||||
|
POSTGRES_USER=docs
|
||||||
|
POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||||
7
env.d/production.dist/yprovider
Normal file
7
env.d/production.dist/yprovider
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
Y_PROVIDER_API_BASE_URL=http://${YPROVIDER_HOST}:4444/api/
|
||||||
|
Y_PROVIDER_API_KEY=<generate a random key>
|
||||||
|
COLLABORATION_SERVER_SECRET=<generate a random key>
|
||||||
|
COLLABORATION_SERVER_ORIGIN=https://${DOCS_HOST}
|
||||||
|
COLLABORATION_API_URL=https://${DOCS_HOST}/collaboration/api/
|
||||||
|
COLLABORATION_BACKEND_BASE_URL=https://${DOCS_HOST}
|
||||||
|
COLLABORATION_LOGGING=true
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
{
|
{
|
||||||
"extends": ["github>numerique-gouv/renovate-configuration"],
|
"extends": ["github>numerique-gouv/renovate-configuration"],
|
||||||
"dependencyDashboard": true,
|
"dependencyDashboard": true,
|
||||||
"labels": ["dependencies", "noChangeLog"],
|
"labels": ["dependencies", "noChangeLog", "automated"],
|
||||||
|
"schedule": ["before 7am on monday"],
|
||||||
|
"prCreation": "not-pending",
|
||||||
|
"rebaseWhen": "conflicted",
|
||||||
|
"updateNotScheduled": false,
|
||||||
"packageRules": [
|
"packageRules": [
|
||||||
{
|
{
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
@@ -9,12 +13,24 @@
|
|||||||
"matchManagers": ["pep621"],
|
"matchManagers": ["pep621"],
|
||||||
"matchPackageNames": []
|
"matchPackageNames": []
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"groupName": "allowed redis versions",
|
||||||
|
"matchManagers": ["pep621"],
|
||||||
|
"matchPackageNames": ["redis"],
|
||||||
|
"allowedVersions": "<6.0.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"groupName": "allowed pylint versions",
|
||||||
|
"matchManagers": ["pep621"],
|
||||||
|
"matchPackageNames": ["pylint"],
|
||||||
|
"allowedVersions": "<4.0.0"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"groupName": "ignored js dependencies",
|
"groupName": "ignored js dependencies",
|
||||||
"matchManagers": ["npm"],
|
"matchManagers": ["npm"],
|
||||||
"matchPackageNames": [
|
"matchPackageNames": [
|
||||||
"eslint",
|
"docx",
|
||||||
"fetch-mock",
|
"fetch-mock",
|
||||||
"node",
|
"node",
|
||||||
"node-fetch",
|
"node-fetch",
|
||||||
|
|||||||
0
secu-audit.md
Normal file
0
secu-audit.md
Normal file
@@ -5,7 +5,6 @@ from django.contrib.auth import admin as auth_admin
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from treebeard.admin import TreeAdmin
|
from treebeard.admin import TreeAdmin
|
||||||
from treebeard.forms import movenodeform_factory
|
|
||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
|
|
||||||
@@ -151,11 +150,12 @@ class DocumentAdmin(TreeAdmin):
|
|||||||
"path",
|
"path",
|
||||||
"depth",
|
"depth",
|
||||||
"numchild",
|
"numchild",
|
||||||
|
"duplicated_from",
|
||||||
|
"attachments",
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
form = movenodeform_factory(models.Document)
|
|
||||||
inlines = (DocumentAccessInline,)
|
inlines = (DocumentAccessInline,)
|
||||||
list_display = (
|
list_display = (
|
||||||
"id",
|
"id",
|
||||||
@@ -166,8 +166,10 @@ class DocumentAdmin(TreeAdmin):
|
|||||||
"updated_at",
|
"updated_at",
|
||||||
)
|
)
|
||||||
readonly_fields = (
|
readonly_fields = (
|
||||||
|
"attachments",
|
||||||
"creator",
|
"creator",
|
||||||
"depth",
|
"depth",
|
||||||
|
"duplicated_from",
|
||||||
"id",
|
"id",
|
||||||
"numchild",
|
"numchild",
|
||||||
"path",
|
"path",
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""API filters for Impress' core application."""
|
"""API filters for Impress' core application."""
|
||||||
|
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
import django_filters
|
import django_filters
|
||||||
@@ -7,13 +9,42 @@ import django_filters
|
|||||||
from core import models
|
from core import models
|
||||||
|
|
||||||
|
|
||||||
class DocumentFilter(django_filters.FilterSet):
|
def remove_accents(value):
|
||||||
|
"""Remove accents from a string (vélo -> velo)."""
|
||||||
|
return "".join(
|
||||||
|
c
|
||||||
|
for c in unicodedata.normalize("NFD", value)
|
||||||
|
if unicodedata.category(c) != "Mn"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AccentInsensitiveCharFilter(django_filters.CharFilter):
|
||||||
"""
|
"""
|
||||||
Custom filter for filtering documents.
|
A custom CharFilter that filters on the accent-insensitive value searched.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
title = django_filters.CharFilter(
|
def filter(self, qs, value):
|
||||||
field_name="title", lookup_expr="icontains", label=_("Title")
|
"""
|
||||||
|
Apply the filter to the queryset using the unaccented version of the field.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
qs: The queryset to filter.
|
||||||
|
value: The value to search for in the unaccented field.
|
||||||
|
Returns:
|
||||||
|
A filtered queryset.
|
||||||
|
"""
|
||||||
|
if value:
|
||||||
|
value = remove_accents(value)
|
||||||
|
return super().filter(qs, value)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentFilter(django_filters.FilterSet):
|
||||||
|
"""
|
||||||
|
Custom filter for filtering documents on title (accent and case insensitive).
|
||||||
|
"""
|
||||||
|
|
||||||
|
title = AccentInsensitiveCharFilter(
|
||||||
|
field_name="title", lookup_expr="unaccent__icontains", label=_("Title")
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -29,6 +60,9 @@ class ListDocumentFilter(DocumentFilter):
|
|||||||
is_creator_me = django_filters.BooleanFilter(
|
is_creator_me = django_filters.BooleanFilter(
|
||||||
method="filter_is_creator_me", label=_("Creator is me")
|
method="filter_is_creator_me", label=_("Creator is me")
|
||||||
)
|
)
|
||||||
|
is_masked = django_filters.BooleanFilter(
|
||||||
|
method="filter_is_masked", label=_("Masked")
|
||||||
|
)
|
||||||
is_favorite = django_filters.BooleanFilter(
|
is_favorite = django_filters.BooleanFilter(
|
||||||
method="filter_is_favorite", label=_("Favorite")
|
method="filter_is_favorite", label=_("Favorite")
|
||||||
)
|
)
|
||||||
@@ -75,3 +109,30 @@ class ListDocumentFilter(DocumentFilter):
|
|||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
return queryset.filter(is_favorite=bool(value))
|
return queryset.filter(is_favorite=bool(value))
|
||||||
|
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
def filter_is_masked(self, queryset, name, value):
|
||||||
|
"""
|
||||||
|
Filter documents based on whether they are masked by the current user.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
- /api/v1.0/documents/?is_masked=true
|
||||||
|
→ Filters documents marked as masked by the logged-in user
|
||||||
|
- /api/v1.0/documents/?is_masked=false
|
||||||
|
→ Filters documents not marked as masked by the logged-in user
|
||||||
|
"""
|
||||||
|
user = self.request.user
|
||||||
|
|
||||||
|
if not user.is_authenticated:
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
queryset_method = queryset.filter if bool(value) else queryset.exclude
|
||||||
|
return queryset_method(link_traces__user=user, link_traces__is_masked=True)
|
||||||
|
|
||||||
|
|
||||||
|
class UserSearchFilter(django_filters.FilterSet):
|
||||||
|
"""
|
||||||
|
Custom filter for searching users.
|
||||||
|
"""
|
||||||
|
|
||||||
|
q = django_filters.CharFilter(min_length=5, max_length=254)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from django.http import Http404
|
|||||||
|
|
||||||
from rest_framework import permissions
|
from rest_framework import permissions
|
||||||
|
|
||||||
|
from core import choices
|
||||||
from core.models import DocumentAccess, RoleChoices, get_trashbin_cutoff
|
from core.models import DocumentAccess, RoleChoices, get_trashbin_cutoff
|
||||||
|
|
||||||
ACTION_FOR_METHOD_TO_PERMISSION = {
|
ACTION_FOR_METHOD_TO_PERMISSION = {
|
||||||
@@ -96,26 +97,27 @@ class CanCreateInvitationPermission(permissions.BasePermission):
|
|||||||
).exists()
|
).exists()
|
||||||
|
|
||||||
|
|
||||||
class AccessPermission(permissions.BasePermission):
|
class ResourceWithAccessPermission(permissions.BasePermission):
|
||||||
"""Permission class for access objects."""
|
"""A permission class for templates and invitations."""
|
||||||
|
|
||||||
def has_permission(self, request, view):
|
def has_permission(self, request, view):
|
||||||
|
"""check create permission for templates."""
|
||||||
return request.user.is_authenticated or view.action != "create"
|
return request.user.is_authenticated or view.action != "create"
|
||||||
|
|
||||||
def has_object_permission(self, request, view, obj):
|
def has_object_permission(self, request, view, obj):
|
||||||
"""Check permission for a given object."""
|
"""Check permission for a given object."""
|
||||||
abilities = obj.get_abilities(request.user)
|
abilities = obj.get_abilities(request.user)
|
||||||
action = view.action
|
action = view.action
|
||||||
try:
|
|
||||||
action = ACTION_FOR_METHOD_TO_PERMISSION[view.action][request.method]
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
return abilities.get(action, False)
|
return abilities.get(action, False)
|
||||||
|
|
||||||
|
|
||||||
class DocumentAccessPermission(AccessPermission):
|
class DocumentPermission(permissions.BasePermission):
|
||||||
"""Subclass to handle soft deletion specificities."""
|
"""Subclass to handle soft deletion specificities."""
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
"""check create permission for documents."""
|
||||||
|
return request.user.is_authenticated or view.action != "create"
|
||||||
|
|
||||||
def has_object_permission(self, request, view, obj):
|
def has_object_permission(self, request, view, obj):
|
||||||
"""
|
"""
|
||||||
Return a 404 on deleted documents
|
Return a 404 on deleted documents
|
||||||
@@ -127,10 +129,61 @@ class DocumentAccessPermission(AccessPermission):
|
|||||||
) and deleted_at < get_trashbin_cutoff():
|
) and deleted_at < get_trashbin_cutoff():
|
||||||
raise Http404
|
raise Http404
|
||||||
|
|
||||||
# Compute permission first to ensure the "user_roles" attribute is set
|
abilities = obj.get_abilities(request.user)
|
||||||
has_permission = super().has_object_permission(request, view, obj)
|
action = view.action
|
||||||
|
try:
|
||||||
|
action = ACTION_FOR_METHOD_TO_PERMISSION[view.action][request.method]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
has_permission = abilities.get(action, False)
|
||||||
|
|
||||||
if obj.ancestors_deleted_at and not RoleChoices.OWNER in obj.user_roles:
|
if obj.ancestors_deleted_at and not RoleChoices.OWNER in obj.user_roles:
|
||||||
raise Http404
|
raise Http404
|
||||||
|
|
||||||
return has_permission
|
return has_permission
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceAccessPermission(IsAuthenticated):
|
||||||
|
"""Permission class for document access objects."""
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
"""check create permission for accesses in documents tree."""
|
||||||
|
if super().has_permission(request, view) is False:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if view.action == "create":
|
||||||
|
role = getattr(view, view.resource_field_name).get_role(request.user)
|
||||||
|
if role not in choices.PRIVILEGED_ROLES:
|
||||||
|
raise exceptions.PermissionDenied(
|
||||||
|
"You are not allowed to manage accesses for this resource."
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def has_object_permission(self, request, view, obj):
|
||||||
|
"""Check permission for a given object."""
|
||||||
|
abilities = obj.get_abilities(request.user)
|
||||||
|
|
||||||
|
requested_role = request.data.get("role")
|
||||||
|
if requested_role and requested_role not in abilities.get("set_role_to", []):
|
||||||
|
return False
|
||||||
|
|
||||||
|
action = view.action
|
||||||
|
return abilities.get(action, False)
|
||||||
|
|
||||||
|
|
||||||
|
class CommentPermission(permissions.BasePermission):
|
||||||
|
"""Permission class for comments."""
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
"""Check permission for a given object."""
|
||||||
|
if view.action in ["create", "list"]:
|
||||||
|
document_abilities = view.get_document_or_404().get_abilities(request.user)
|
||||||
|
return document_abilities["comment"]
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def has_object_permission(self, request, view, obj):
|
||||||
|
"""Check permission for a given object."""
|
||||||
|
return obj.get_abilities(request.user).get(view.action, False)
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
"""Client serializers for the impress core app."""
|
"""Client serializers for the impress core app."""
|
||||||
|
# pylint: disable=too-many-lines
|
||||||
|
|
||||||
|
import binascii
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
from base64 import b64decode
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.utils.functional import lazy
|
from django.utils.functional import lazy
|
||||||
|
from django.utils.text import slugify
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
import magic
|
import magic
|
||||||
from rest_framework import exceptions, serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from core import enums, models
|
from core import choices, enums, models, utils, validators
|
||||||
from core.services.ai_services import AI_ACTIONS
|
from core.services.ai_services import AI_ACTIONS
|
||||||
from core.services.converter_services import (
|
from core.services.converter_services import (
|
||||||
ConversionError,
|
ConversionError,
|
||||||
@@ -21,112 +25,63 @@ from core.services.converter_services import (
|
|||||||
class UserSerializer(serializers.ModelSerializer):
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
"""Serialize users."""
|
"""Serialize users."""
|
||||||
|
|
||||||
|
full_name = serializers.SerializerMethodField(read_only=True)
|
||||||
|
short_name = serializers.SerializerMethodField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.User
|
model = models.User
|
||||||
fields = ["id", "email", "full_name", "short_name", "language"]
|
fields = ["id", "email", "full_name", "short_name", "language"]
|
||||||
read_only_fields = ["id", "email", "full_name", "short_name"]
|
read_only_fields = ["id", "email", "full_name", "short_name"]
|
||||||
|
|
||||||
|
def get_full_name(self, instance):
|
||||||
|
"""Return the full name of the user."""
|
||||||
|
if not instance.full_name:
|
||||||
|
email = instance.email.split("@")[0]
|
||||||
|
return slugify(email)
|
||||||
|
|
||||||
class BaseAccessSerializer(serializers.ModelSerializer):
|
return instance.full_name
|
||||||
|
|
||||||
|
def get_short_name(self, instance):
|
||||||
|
"""Return the short name of the user."""
|
||||||
|
if not instance.short_name:
|
||||||
|
email = instance.email.split("@")[0]
|
||||||
|
return slugify(email)
|
||||||
|
|
||||||
|
return instance.short_name
|
||||||
|
|
||||||
|
|
||||||
|
class UserLightSerializer(UserSerializer):
|
||||||
|
"""Serialize users with limited fields."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.User
|
||||||
|
fields = ["full_name", "short_name"]
|
||||||
|
read_only_fields = ["full_name", "short_name"]
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateAccessSerializer(serializers.ModelSerializer):
|
||||||
"""Serialize template accesses."""
|
"""Serialize template accesses."""
|
||||||
|
|
||||||
abilities = serializers.SerializerMethodField(read_only=True)
|
abilities = serializers.SerializerMethodField(read_only=True)
|
||||||
|
|
||||||
def update(self, instance, validated_data):
|
|
||||||
"""Make "user" field is readonly but only on update."""
|
|
||||||
validated_data.pop("user", None)
|
|
||||||
return super().update(instance, validated_data)
|
|
||||||
|
|
||||||
def get_abilities(self, access) -> dict:
|
|
||||||
"""Return abilities of the logged-in user on the instance."""
|
|
||||||
request = self.context.get("request")
|
|
||||||
if request:
|
|
||||||
return access.get_abilities(request.user)
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def validate(self, attrs):
|
|
||||||
"""
|
|
||||||
Check access rights specific to writing (create/update)
|
|
||||||
"""
|
|
||||||
request = self.context.get("request")
|
|
||||||
user = getattr(request, "user", None)
|
|
||||||
role = attrs.get("role")
|
|
||||||
|
|
||||||
# Update
|
|
||||||
if self.instance:
|
|
||||||
can_set_role_to = self.instance.get_abilities(user)["set_role_to"]
|
|
||||||
|
|
||||||
if role and role not in can_set_role_to:
|
|
||||||
message = (
|
|
||||||
f"You are only allowed to set role to {', '.join(can_set_role_to)}"
|
|
||||||
if can_set_role_to
|
|
||||||
else "You are not allowed to set this role for this template."
|
|
||||||
)
|
|
||||||
raise exceptions.PermissionDenied(message)
|
|
||||||
|
|
||||||
# Create
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
resource_id = self.context["resource_id"]
|
|
||||||
except KeyError as exc:
|
|
||||||
raise exceptions.ValidationError(
|
|
||||||
"You must set a resource ID in kwargs to create a new access."
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
if not self.Meta.model.objects.filter( # pylint: disable=no-member
|
|
||||||
Q(user=user) | Q(team__in=user.teams),
|
|
||||||
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
|
|
||||||
**{self.Meta.resource_field_name: resource_id}, # pylint: disable=no-member
|
|
||||||
).exists():
|
|
||||||
raise exceptions.PermissionDenied(
|
|
||||||
"You are not allowed to manage accesses for this resource."
|
|
||||||
)
|
|
||||||
|
|
||||||
if (
|
|
||||||
role == models.RoleChoices.OWNER
|
|
||||||
and not self.Meta.model.objects.filter( # pylint: disable=no-member
|
|
||||||
Q(user=user) | Q(team__in=user.teams),
|
|
||||||
role=models.RoleChoices.OWNER,
|
|
||||||
**{self.Meta.resource_field_name: resource_id}, # pylint: disable=no-member
|
|
||||||
).exists()
|
|
||||||
):
|
|
||||||
raise exceptions.PermissionDenied(
|
|
||||||
"Only owners of a resource can assign other users as owners."
|
|
||||||
)
|
|
||||||
|
|
||||||
# pylint: disable=no-member
|
|
||||||
attrs[f"{self.Meta.resource_field_name}_id"] = self.context["resource_id"]
|
|
||||||
return attrs
|
|
||||||
|
|
||||||
|
|
||||||
class DocumentAccessSerializer(BaseAccessSerializer):
|
|
||||||
"""Serialize document accesses."""
|
|
||||||
|
|
||||||
user_id = serializers.PrimaryKeyRelatedField(
|
|
||||||
queryset=models.User.objects.all(),
|
|
||||||
write_only=True,
|
|
||||||
source="user",
|
|
||||||
required=False,
|
|
||||||
allow_null=True,
|
|
||||||
)
|
|
||||||
user = UserSerializer(read_only=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = models.DocumentAccess
|
|
||||||
resource_field_name = "document"
|
|
||||||
fields = ["id", "user", "user_id", "team", "role", "abilities"]
|
|
||||||
read_only_fields = ["id", "abilities"]
|
|
||||||
|
|
||||||
|
|
||||||
class TemplateAccessSerializer(BaseAccessSerializer):
|
|
||||||
"""Serialize template accesses."""
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.TemplateAccess
|
model = models.TemplateAccess
|
||||||
resource_field_name = "template"
|
resource_field_name = "template"
|
||||||
fields = ["id", "user", "team", "role", "abilities"]
|
fields = ["id", "user", "team", "role", "abilities"]
|
||||||
read_only_fields = ["id", "abilities"]
|
read_only_fields = ["id", "abilities"]
|
||||||
|
|
||||||
|
def get_abilities(self, instance) -> dict:
|
||||||
|
"""Return abilities of the logged-in user on the instance."""
|
||||||
|
request = self.context.get("request")
|
||||||
|
if request:
|
||||||
|
return instance.get_abilities(request.user)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
"""Make "user" field is readonly but only on update."""
|
||||||
|
validated_data.pop("user", None)
|
||||||
|
return super().update(instance, validated_data)
|
||||||
|
|
||||||
|
|
||||||
class ListDocumentSerializer(serializers.ModelSerializer):
|
class ListDocumentSerializer(serializers.ModelSerializer):
|
||||||
"""Serialize documents with limited fields for display in lists."""
|
"""Serialize documents with limited fields for display in lists."""
|
||||||
@@ -134,16 +89,22 @@ class ListDocumentSerializer(serializers.ModelSerializer):
|
|||||||
is_favorite = serializers.BooleanField(read_only=True)
|
is_favorite = serializers.BooleanField(read_only=True)
|
||||||
nb_accesses_ancestors = serializers.IntegerField(read_only=True)
|
nb_accesses_ancestors = serializers.IntegerField(read_only=True)
|
||||||
nb_accesses_direct = serializers.IntegerField(read_only=True)
|
nb_accesses_direct = serializers.IntegerField(read_only=True)
|
||||||
user_roles = serializers.SerializerMethodField(read_only=True)
|
user_role = serializers.SerializerMethodField(read_only=True)
|
||||||
abilities = serializers.SerializerMethodField(read_only=True)
|
abilities = serializers.SerializerMethodField(read_only=True)
|
||||||
|
deleted_at = serializers.SerializerMethodField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Document
|
model = models.Document
|
||||||
fields = [
|
fields = [
|
||||||
"id",
|
"id",
|
||||||
"abilities",
|
"abilities",
|
||||||
|
"ancestors_link_reach",
|
||||||
|
"ancestors_link_role",
|
||||||
|
"computed_link_reach",
|
||||||
|
"computed_link_role",
|
||||||
"created_at",
|
"created_at",
|
||||||
"creator",
|
"creator",
|
||||||
|
"deleted_at",
|
||||||
"depth",
|
"depth",
|
||||||
"excerpt",
|
"excerpt",
|
||||||
"is_favorite",
|
"is_favorite",
|
||||||
@@ -155,13 +116,18 @@ class ListDocumentSerializer(serializers.ModelSerializer):
|
|||||||
"path",
|
"path",
|
||||||
"title",
|
"title",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
"user_roles",
|
"user_role",
|
||||||
]
|
]
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
"id",
|
"id",
|
||||||
"abilities",
|
"abilities",
|
||||||
|
"ancestors_link_reach",
|
||||||
|
"ancestors_link_role",
|
||||||
|
"computed_link_reach",
|
||||||
|
"computed_link_role",
|
||||||
"created_at",
|
"created_at",
|
||||||
"creator",
|
"creator",
|
||||||
|
"deleted_at",
|
||||||
"depth",
|
"depth",
|
||||||
"excerpt",
|
"excerpt",
|
||||||
"is_favorite",
|
"is_favorite",
|
||||||
@@ -172,49 +138,70 @@ class ListDocumentSerializer(serializers.ModelSerializer):
|
|||||||
"numchild",
|
"numchild",
|
||||||
"path",
|
"path",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
"user_roles",
|
"user_role",
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_abilities(self, document) -> dict:
|
def to_representation(self, instance):
|
||||||
|
"""Precompute once per instance"""
|
||||||
|
paths_links_mapping = self.context.get("paths_links_mapping")
|
||||||
|
|
||||||
|
if paths_links_mapping is not None:
|
||||||
|
links = paths_links_mapping.get(instance.path[: -instance.steplen], [])
|
||||||
|
instance.ancestors_link_definition = choices.get_equivalent_link_definition(
|
||||||
|
links
|
||||||
|
)
|
||||||
|
|
||||||
|
return super().to_representation(instance)
|
||||||
|
|
||||||
|
def get_abilities(self, instance) -> dict:
|
||||||
"""Return abilities of the logged-in user on the instance."""
|
"""Return abilities of the logged-in user on the instance."""
|
||||||
request = self.context.get("request")
|
request = self.context.get("request")
|
||||||
|
if not request:
|
||||||
|
return {}
|
||||||
|
|
||||||
if request:
|
return instance.get_abilities(request.user)
|
||||||
paths_links_mapping = self.context.get("paths_links_mapping", None)
|
|
||||||
# Retrieve ancestor links from paths_links_mapping (if provided)
|
|
||||||
ancestors_links = (
|
|
||||||
paths_links_mapping.get(document.path[: -document.steplen])
|
|
||||||
if paths_links_mapping
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
return document.get_abilities(request.user, ancestors_links=ancestors_links)
|
|
||||||
|
|
||||||
return {}
|
def get_user_role(self, instance):
|
||||||
|
|
||||||
def get_user_roles(self, document):
|
|
||||||
"""
|
"""
|
||||||
Return roles of the logged-in user for the current document,
|
Return roles of the logged-in user for the current document,
|
||||||
taking into account ancestors.
|
taking into account ancestors.
|
||||||
"""
|
"""
|
||||||
request = self.context.get("request")
|
request = self.context.get("request")
|
||||||
if request:
|
return instance.get_role(request.user) if request else None
|
||||||
return document.get_roles(request.user)
|
|
||||||
return []
|
def get_deleted_at(self, instance):
|
||||||
|
"""Return the deleted_at of the current document."""
|
||||||
|
return instance.ancestors_deleted_at
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentLightSerializer(serializers.ModelSerializer):
|
||||||
|
"""Minial document serializer for nesting in document accesses."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.Document
|
||||||
|
fields = ["id", "path", "depth"]
|
||||||
|
read_only_fields = ["id", "path", "depth"]
|
||||||
|
|
||||||
|
|
||||||
class DocumentSerializer(ListDocumentSerializer):
|
class DocumentSerializer(ListDocumentSerializer):
|
||||||
"""Serialize documents with all fields for display in detail views."""
|
"""Serialize documents with all fields for display in detail views."""
|
||||||
|
|
||||||
content = serializers.CharField(required=False)
|
content = serializers.CharField(required=False)
|
||||||
|
websocket = serializers.BooleanField(required=False, write_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Document
|
model = models.Document
|
||||||
fields = [
|
fields = [
|
||||||
"id",
|
"id",
|
||||||
"abilities",
|
"abilities",
|
||||||
|
"ancestors_link_reach",
|
||||||
|
"ancestors_link_role",
|
||||||
|
"computed_link_reach",
|
||||||
|
"computed_link_role",
|
||||||
"content",
|
"content",
|
||||||
"created_at",
|
"created_at",
|
||||||
"creator",
|
"creator",
|
||||||
|
"deleted_at",
|
||||||
"depth",
|
"depth",
|
||||||
"excerpt",
|
"excerpt",
|
||||||
"is_favorite",
|
"is_favorite",
|
||||||
@@ -226,13 +213,19 @@ class DocumentSerializer(ListDocumentSerializer):
|
|||||||
"path",
|
"path",
|
||||||
"title",
|
"title",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
"user_roles",
|
"user_role",
|
||||||
|
"websocket",
|
||||||
]
|
]
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
"id",
|
"id",
|
||||||
"abilities",
|
"abilities",
|
||||||
|
"ancestors_link_reach",
|
||||||
|
"ancestors_link_role",
|
||||||
|
"computed_link_reach",
|
||||||
|
"computed_link_role",
|
||||||
"created_at",
|
"created_at",
|
||||||
"creator",
|
"creator",
|
||||||
|
"deleted_at",
|
||||||
"depth",
|
"depth",
|
||||||
"is_favorite",
|
"is_favorite",
|
||||||
"link_role",
|
"link_role",
|
||||||
@@ -242,7 +235,7 @@ class DocumentSerializer(ListDocumentSerializer):
|
|||||||
"numchild",
|
"numchild",
|
||||||
"path",
|
"path",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
"user_roles",
|
"user_role",
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_fields(self):
|
def get_fields(self):
|
||||||
@@ -268,6 +261,158 @@ class DocumentSerializer(ListDocumentSerializer):
|
|||||||
|
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
def validate_content(self, value):
|
||||||
|
"""Validate the content field."""
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
b64decode(value, validate=True)
|
||||||
|
except binascii.Error as err:
|
||||||
|
raise serializers.ValidationError("Invalid base64 content.") from err
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
def save(self, **kwargs):
|
||||||
|
"""
|
||||||
|
Process the content field to extract attachment keys and update the document's
|
||||||
|
"attachments" field for access control.
|
||||||
|
"""
|
||||||
|
content = self.validated_data.get("content", "")
|
||||||
|
extracted_attachments = set(utils.extract_attachments(content))
|
||||||
|
|
||||||
|
existing_attachments = (
|
||||||
|
set(self.instance.attachments or []) if self.instance else set()
|
||||||
|
)
|
||||||
|
new_attachments = extracted_attachments - existing_attachments
|
||||||
|
|
||||||
|
if new_attachments:
|
||||||
|
attachments_documents = (
|
||||||
|
models.Document.objects.filter(
|
||||||
|
attachments__overlap=list(new_attachments)
|
||||||
|
)
|
||||||
|
.only("path", "attachments")
|
||||||
|
.order_by("path")
|
||||||
|
)
|
||||||
|
|
||||||
|
user = self.context["request"].user
|
||||||
|
readable_per_se_paths = (
|
||||||
|
models.Document.objects.readable_per_se(user)
|
||||||
|
.order_by("path")
|
||||||
|
.values_list("path", flat=True)
|
||||||
|
)
|
||||||
|
readable_attachments_paths = utils.filter_descendants(
|
||||||
|
[doc.path for doc in attachments_documents],
|
||||||
|
readable_per_se_paths,
|
||||||
|
skip_sorting=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
readable_attachments = set()
|
||||||
|
for document in attachments_documents:
|
||||||
|
if document.path not in readable_attachments_paths:
|
||||||
|
continue
|
||||||
|
readable_attachments.update(set(document.attachments) & new_attachments)
|
||||||
|
|
||||||
|
# Update attachments with readable keys
|
||||||
|
self.validated_data["attachments"] = list(
|
||||||
|
existing_attachments | readable_attachments
|
||||||
|
)
|
||||||
|
|
||||||
|
return super().save(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentAccessSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serialize document accesses."""
|
||||||
|
|
||||||
|
document = DocumentLightSerializer(read_only=True)
|
||||||
|
user_id = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=models.User.objects.all(),
|
||||||
|
write_only=True,
|
||||||
|
source="user",
|
||||||
|
required=False,
|
||||||
|
allow_null=True,
|
||||||
|
)
|
||||||
|
user = UserSerializer(read_only=True)
|
||||||
|
team = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
abilities = serializers.SerializerMethodField(read_only=True)
|
||||||
|
max_ancestors_role = serializers.SerializerMethodField(read_only=True)
|
||||||
|
max_role = serializers.SerializerMethodField(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.DocumentAccess
|
||||||
|
resource_field_name = "document"
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"document",
|
||||||
|
"user",
|
||||||
|
"user_id",
|
||||||
|
"team",
|
||||||
|
"role",
|
||||||
|
"abilities",
|
||||||
|
"max_ancestors_role",
|
||||||
|
"max_role",
|
||||||
|
]
|
||||||
|
read_only_fields = [
|
||||||
|
"id",
|
||||||
|
"document",
|
||||||
|
"abilities",
|
||||||
|
"max_ancestors_role",
|
||||||
|
"max_role",
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_abilities(self, instance) -> dict:
|
||||||
|
"""Return abilities of the logged-in user on the instance."""
|
||||||
|
request = self.context.get("request")
|
||||||
|
if request:
|
||||||
|
return instance.get_abilities(request.user)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def get_max_ancestors_role(self, instance):
|
||||||
|
"""Return max_ancestors_role if annotated; else None."""
|
||||||
|
return getattr(instance, "max_ancestors_role", None)
|
||||||
|
|
||||||
|
def get_max_role(self, instance):
|
||||||
|
"""Return max_ancestors_role if annotated; else None."""
|
||||||
|
return choices.RoleChoices.max(
|
||||||
|
getattr(instance, "max_ancestors_role", None),
|
||||||
|
instance.role,
|
||||||
|
)
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
"""Make "user" field readonly but only on update."""
|
||||||
|
validated_data.pop("team", None)
|
||||||
|
validated_data.pop("user", None)
|
||||||
|
return super().update(instance, validated_data)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentAccessLightSerializer(DocumentAccessSerializer):
|
||||||
|
"""Serialize document accesses with limited fields."""
|
||||||
|
|
||||||
|
user = UserLightSerializer(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.DocumentAccess
|
||||||
|
resource_field_name = "document"
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"document",
|
||||||
|
"user",
|
||||||
|
"team",
|
||||||
|
"role",
|
||||||
|
"abilities",
|
||||||
|
"max_ancestors_role",
|
||||||
|
"max_role",
|
||||||
|
]
|
||||||
|
read_only_fields = [
|
||||||
|
"id",
|
||||||
|
"document",
|
||||||
|
"team",
|
||||||
|
"role",
|
||||||
|
"abilities",
|
||||||
|
"max_ancestors_role",
|
||||||
|
"max_role",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class ServerCreateDocumentSerializer(serializers.Serializer):
|
class ServerCreateDocumentSerializer(serializers.Serializer):
|
||||||
"""
|
"""
|
||||||
@@ -287,7 +432,7 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
|
|||||||
content = serializers.CharField(required=True)
|
content = serializers.CharField(required=True)
|
||||||
# User
|
# User
|
||||||
sub = serializers.CharField(
|
sub = serializers.CharField(
|
||||||
required=True, validators=[models.User.sub_validator], max_length=255
|
required=True, validators=[validators.sub_validator], max_length=255
|
||||||
)
|
)
|
||||||
email = serializers.EmailField(required=True)
|
email = serializers.EmailField(required=True)
|
||||||
language = serializers.ChoiceField(
|
language = serializers.ChoiceField(
|
||||||
@@ -316,9 +461,7 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
|
|||||||
language = user.language or language
|
language = user.language or language
|
||||||
|
|
||||||
try:
|
try:
|
||||||
document_content = YdocConverter().convert_markdown(
|
document_content = YdocConverter().convert(validated_data["content"])
|
||||||
validated_data["content"]
|
|
||||||
)
|
|
||||||
except ConversionError as err:
|
except ConversionError as err:
|
||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError(
|
||||||
{"content": ["Could not convert content"]}
|
{"content": ["Could not convert content"]}
|
||||||
@@ -373,6 +516,10 @@ class LinkDocumentSerializer(serializers.ModelSerializer):
|
|||||||
We expose it separately from document in order to simplify and secure access control.
|
We expose it separately from document in order to simplify and secure access control.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
link_reach = serializers.ChoiceField(
|
||||||
|
choices=models.LinkReachChoices.choices, required=True
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Document
|
model = models.Document
|
||||||
fields = [
|
fields = [
|
||||||
@@ -380,6 +527,79 @@ class LinkDocumentSerializer(serializers.ModelSerializer):
|
|||||||
"link_reach",
|
"link_reach",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
"""Validate that link_role and link_reach are compatible using get_select_options."""
|
||||||
|
link_reach = attrs.get("link_reach")
|
||||||
|
link_role = attrs.get("link_role")
|
||||||
|
|
||||||
|
if not link_reach:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{"link_reach": _("This field is required.")}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get available options based on ancestors' link definition
|
||||||
|
available_options = models.LinkReachChoices.get_select_options(
|
||||||
|
**self.instance.ancestors_link_definition
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate link_reach is allowed
|
||||||
|
if link_reach not in available_options:
|
||||||
|
msg = _(
|
||||||
|
"Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||||
|
)
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{"link_reach": msg % {"link_reach": link_reach}}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate link_role is compatible with link_reach
|
||||||
|
allowed_roles = available_options[link_reach]
|
||||||
|
|
||||||
|
# Restricted reach: link_role must be None
|
||||||
|
if link_reach == models.LinkReachChoices.RESTRICTED:
|
||||||
|
if link_role is not None:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{
|
||||||
|
"link_role": (
|
||||||
|
"Cannot set link_role when link_reach is 'restricted'. "
|
||||||
|
"Link role must be null for restricted reach."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return attrs
|
||||||
|
# Non-restricted: link_role must be in allowed roles
|
||||||
|
if link_role not in allowed_roles:
|
||||||
|
allowed_roles_str = ", ".join(allowed_roles) if allowed_roles else "none"
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{
|
||||||
|
"link_role": (
|
||||||
|
f"Link role '{link_role}' is not allowed for link reach '{link_reach}'. "
|
||||||
|
f"Allowed roles: {allowed_roles_str}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentDuplicationSerializer(serializers.Serializer):
|
||||||
|
"""
|
||||||
|
Serializer for duplicating a document.
|
||||||
|
Allows specifying whether to keep access permissions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
with_accesses = serializers.BooleanField(default=False)
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
"""
|
||||||
|
This serializer is not intended to create objects.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError("This serializer does not support creation.")
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
"""
|
||||||
|
This serializer is not intended to update objects.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError("This serializer does not support updating.")
|
||||||
|
|
||||||
|
|
||||||
# Suppress the warning about not implementing `create` and `update` methods
|
# Suppress the warning about not implementing `create` and `update` methods
|
||||||
# since we don't use a model and only rely on the serializer for validation
|
# since we don't use a model and only rely on the serializer for validation
|
||||||
@@ -404,16 +624,17 @@ class FileUploadSerializer(serializers.Serializer):
|
|||||||
mime = magic.Magic(mime=True)
|
mime = magic.Magic(mime=True)
|
||||||
magic_mime_type = mime.from_buffer(file.read(1024))
|
magic_mime_type = mime.from_buffer(file.read(1024))
|
||||||
file.seek(0) # Reset file pointer to the beginning after reading
|
file.seek(0) # Reset file pointer to the beginning after reading
|
||||||
|
self.context["is_unsafe"] = False
|
||||||
|
if settings.DOCUMENT_ATTACHMENT_CHECK_UNSAFE_MIME_TYPES_ENABLED:
|
||||||
|
self.context["is_unsafe"] = (
|
||||||
|
magic_mime_type in settings.DOCUMENT_UNSAFE_MIME_TYPES
|
||||||
|
)
|
||||||
|
|
||||||
self.context["is_unsafe"] = (
|
extension_mime_type, _ = mimetypes.guess_type(file.name)
|
||||||
magic_mime_type in settings.DOCUMENT_UNSAFE_MIME_TYPES
|
|
||||||
)
|
|
||||||
|
|
||||||
extension_mime_type, _ = mimetypes.guess_type(file.name)
|
# Try guessing a coherent extension from the mimetype
|
||||||
|
if extension_mime_type != magic_mime_type:
|
||||||
# Try guessing a coherent extension from the mimetype
|
self.context["is_unsafe"] = True
|
||||||
if extension_mime_type != magic_mime_type:
|
|
||||||
self.context["is_unsafe"] = True
|
|
||||||
|
|
||||||
guessed_ext = mimetypes.guess_extension(magic_mime_type)
|
guessed_ext = mimetypes.guess_extension(magic_mime_type)
|
||||||
# Missing extensions or extensions longer than 5 characters (it's as long as an extension
|
# Missing extensions or extensions longer than 5 characters (it's as long as an extension
|
||||||
@@ -529,6 +750,9 @@ class InvitationSerializer(serializers.ModelSerializer):
|
|||||||
if self.instance is None:
|
if self.instance is None:
|
||||||
attrs["issuer"] = user
|
attrs["issuer"] = user
|
||||||
|
|
||||||
|
if attrs.get("email"):
|
||||||
|
attrs["email"] = attrs["email"].lower()
|
||||||
|
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
def validate_role(self, role):
|
def validate_role(self, role):
|
||||||
@@ -551,6 +775,52 @@ class InvitationSerializer(serializers.ModelSerializer):
|
|||||||
return role
|
return role
|
||||||
|
|
||||||
|
|
||||||
|
class RoleSerializer(serializers.Serializer):
|
||||||
|
"""Serializer validating role choices."""
|
||||||
|
|
||||||
|
role = serializers.ChoiceField(
|
||||||
|
choices=models.RoleChoices.choices, required=False, allow_null=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentAskForAccessCreateSerializer(serializers.Serializer):
|
||||||
|
"""Serializer for creating a document ask for access."""
|
||||||
|
|
||||||
|
role = serializers.ChoiceField(
|
||||||
|
choices=[
|
||||||
|
role for role in choices.RoleChoices if role != models.RoleChoices.OWNER
|
||||||
|
],
|
||||||
|
required=False,
|
||||||
|
default=models.RoleChoices.READER,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentAskForAccessSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serializer for document ask for access model"""
|
||||||
|
|
||||||
|
abilities = serializers.SerializerMethodField(read_only=True)
|
||||||
|
user = UserSerializer(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.DocumentAskForAccess
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"document",
|
||||||
|
"user",
|
||||||
|
"role",
|
||||||
|
"created_at",
|
||||||
|
"abilities",
|
||||||
|
]
|
||||||
|
read_only_fields = ["id", "document", "user", "role", "created_at", "abilities"]
|
||||||
|
|
||||||
|
def get_abilities(self, instance) -> dict:
|
||||||
|
"""Return abilities of the logged-in user on the instance."""
|
||||||
|
request = self.context.get("request")
|
||||||
|
if request:
|
||||||
|
return instance.get_abilities(request.user)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
class VersionFilterSerializer(serializers.Serializer):
|
class VersionFilterSerializer(serializers.Serializer):
|
||||||
"""Validate version filters applied to the list endpoint."""
|
"""Validate version filters applied to the list endpoint."""
|
||||||
|
|
||||||
@@ -622,3 +892,124 @@ class MoveDocumentSerializer(serializers.Serializer):
|
|||||||
choices=enums.MoveNodePositionChoices.choices,
|
choices=enums.MoveNodePositionChoices.choices,
|
||||||
default=enums.MoveNodePositionChoices.LAST_CHILD,
|
default=enums.MoveNodePositionChoices.LAST_CHILD,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ReactionSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serialize reactions."""
|
||||||
|
|
||||||
|
users = UserLightSerializer(many=True, read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.Reaction
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"emoji",
|
||||||
|
"created_at",
|
||||||
|
"users",
|
||||||
|
]
|
||||||
|
read_only_fields = ["id", "created_at", "users"]
|
||||||
|
|
||||||
|
|
||||||
|
class CommentSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serialize comments (nested under a thread) with reactions and abilities."""
|
||||||
|
|
||||||
|
user = UserLightSerializer(read_only=True)
|
||||||
|
abilities = serializers.SerializerMethodField()
|
||||||
|
reactions = ReactionSerializer(many=True, read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.Comment
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"user",
|
||||||
|
"body",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"reactions",
|
||||||
|
"abilities",
|
||||||
|
]
|
||||||
|
read_only_fields = [
|
||||||
|
"id",
|
||||||
|
"user",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"reactions",
|
||||||
|
"abilities",
|
||||||
|
]
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
"""Validate comment data."""
|
||||||
|
|
||||||
|
request = self.context.get("request")
|
||||||
|
user = getattr(request, "user", None)
|
||||||
|
|
||||||
|
attrs["thread_id"] = self.context["thread_id"]
|
||||||
|
attrs["user_id"] = user.id if user else None
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
def get_abilities(self, obj):
|
||||||
|
"""Return comment's abilities."""
|
||||||
|
request = self.context.get("request")
|
||||||
|
if request:
|
||||||
|
return obj.get_abilities(request.user)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
class ThreadSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serialize threads in a backward compatible shape for current frontend.
|
||||||
|
|
||||||
|
We expose a flatten representation where ``content`` maps to the first
|
||||||
|
comment's body. Creating a thread requires a ``content`` field which is
|
||||||
|
stored as the first comment.
|
||||||
|
"""
|
||||||
|
|
||||||
|
creator = UserLightSerializer(read_only=True)
|
||||||
|
abilities = serializers.SerializerMethodField(read_only=True)
|
||||||
|
body = serializers.JSONField(write_only=True, required=True)
|
||||||
|
comments = serializers.SerializerMethodField(read_only=True)
|
||||||
|
comments = CommentSerializer(many=True, read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.Thread
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"body",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"creator",
|
||||||
|
"abilities",
|
||||||
|
"comments",
|
||||||
|
"resolved",
|
||||||
|
"resolved_at",
|
||||||
|
"resolved_by",
|
||||||
|
"metadata",
|
||||||
|
]
|
||||||
|
read_only_fields = [
|
||||||
|
"id",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"creator",
|
||||||
|
"abilities",
|
||||||
|
"comments",
|
||||||
|
"resolved",
|
||||||
|
"resolved_at",
|
||||||
|
"resolved_by",
|
||||||
|
"metadata",
|
||||||
|
]
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
"""Validate thread data."""
|
||||||
|
request = self.context.get("request")
|
||||||
|
user = getattr(request, "user", None)
|
||||||
|
|
||||||
|
attrs["document_id"] = self.context["resource_id"]
|
||||||
|
attrs["creator_id"] = user.id if user else None
|
||||||
|
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
def get_abilities(self, thread):
|
||||||
|
"""Return thread's abilities."""
|
||||||
|
request = self.context.get("request")
|
||||||
|
if request:
|
||||||
|
return thread.get_abilities(request.user)
|
||||||
|
return {}
|
||||||
|
|||||||
21
src/backend/core/api/throttling.py
Normal file
21
src/backend/core/api/throttling.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"""Throttling modules for the API."""
|
||||||
|
|
||||||
|
from rest_framework.throttling import UserRateThrottle
|
||||||
|
from sentry_sdk import capture_message
|
||||||
|
|
||||||
|
|
||||||
|
def sentry_monitoring_throttle_failure(message):
|
||||||
|
"""Log when a failure occurs to detect rate limiting issues."""
|
||||||
|
capture_message(message, "warning")
|
||||||
|
|
||||||
|
|
||||||
|
class UserListThrottleBurst(UserRateThrottle):
|
||||||
|
"""Throttle for the user list endpoint."""
|
||||||
|
|
||||||
|
scope = "user_list_burst"
|
||||||
|
|
||||||
|
|
||||||
|
class UserListThrottleSustained(UserRateThrottle):
|
||||||
|
"""Throttle for the user list endpoint."""
|
||||||
|
|
||||||
|
scope = "user_list_sustained"
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,130 +1,59 @@
|
|||||||
"""Authentication Backends for the Impress core app."""
|
"""Authentication Backends for the Impress core app."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import SuspiciousOperation
|
from django.core.exceptions import SuspiciousOperation
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
|
|
||||||
import requests
|
from lasuite.oidc_login.backends import (
|
||||||
from mozilla_django_oidc.auth import (
|
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
|
||||||
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
from core.models import DuplicateEmailError, User
|
from core.models import DuplicateEmailError
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Settings renamed warnings
|
||||||
|
if os.environ.get("USER_OIDC_FIELDS_TO_FULLNAME"):
|
||||||
|
logger.warning(
|
||||||
|
"USER_OIDC_FIELDS_TO_FULLNAME has been renamed to "
|
||||||
|
"OIDC_USERINFO_FULLNAME_FIELDS please update your settings."
|
||||||
|
)
|
||||||
|
|
||||||
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
if os.environ.get("USER_OIDC_FIELD_TO_SHORTNAME"):
|
||||||
|
logger.warning(
|
||||||
|
"USER_OIDC_FIELD_TO_SHORTNAME has been renamed to "
|
||||||
|
"OIDC_USERINFO_SHORTNAME_FIELD please update your settings."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||||
"""Custom OpenID Connect (OIDC) Authentication Backend.
|
"""Custom OpenID Connect (OIDC) Authentication Backend.
|
||||||
|
|
||||||
This class overrides the default OIDC Authentication Backend to accommodate differences
|
This class overrides the default OIDC Authentication Backend to accommodate differences
|
||||||
in the User and Identity models, and handles signed and/or encrypted UserInfo response.
|
in the User and Identity models, and handles signed and/or encrypted UserInfo response.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def get_userinfo(self, access_token, id_token, payload):
|
def get_extra_claims(self, user_info):
|
||||||
"""Return user details dictionary.
|
"""
|
||||||
|
Return extra claims from user_info.
|
||||||
|
|
||||||
Parameters:
|
Args:
|
||||||
- access_token (str): The access token.
|
user_info (dict): The user information dictionary.
|
||||||
- id_token (str): The id token (unused).
|
|
||||||
- payload (dict): The token payload (unused).
|
|
||||||
|
|
||||||
Note: The id_token and payload parameters are unused in this implementation,
|
|
||||||
but were kept to preserve base method signature.
|
|
||||||
|
|
||||||
Note: It handles signed and/or encrypted UserInfo Response. It is required by
|
|
||||||
Agent Connect, which follows the OIDC standard. It forces us to override the
|
|
||||||
base method, which deal with 'application/json' response.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
- dict: User details dictionary obtained from the OpenID Connect user endpoint.
|
dict: A dictionary of extra claims.
|
||||||
"""
|
"""
|
||||||
|
return {
|
||||||
user_response = requests.get(
|
"full_name": self.compute_full_name(user_info),
|
||||||
self.OIDC_OP_USER_ENDPOINT,
|
"short_name": user_info.get(settings.OIDC_USERINFO_SHORTNAME_FIELD),
|
||||||
headers={"Authorization": f"Bearer {access_token}"},
|
|
||||||
verify=self.get_settings("OIDC_VERIFY_SSL", True),
|
|
||||||
timeout=self.get_settings("OIDC_TIMEOUT", None),
|
|
||||||
proxies=self.get_settings("OIDC_PROXY", None),
|
|
||||||
)
|
|
||||||
user_response.raise_for_status()
|
|
||||||
|
|
||||||
try:
|
|
||||||
userinfo = user_response.json()
|
|
||||||
except ValueError:
|
|
||||||
try:
|
|
||||||
userinfo = self.verify_token(user_response.text)
|
|
||||||
except Exception as e:
|
|
||||||
raise SuspiciousOperation(
|
|
||||||
_("Invalid response format or token verification failed")
|
|
||||||
) from e
|
|
||||||
|
|
||||||
return userinfo
|
|
||||||
|
|
||||||
def verify_claims(self, claims):
|
|
||||||
"""
|
|
||||||
Verify the presence of essential claims and the "sub" (which is mandatory as defined
|
|
||||||
by the OIDC specification) to decide if authentication should be allowed.
|
|
||||||
"""
|
|
||||||
essential_claims = settings.USER_OIDC_ESSENTIAL_CLAIMS
|
|
||||||
missing_claims = [claim for claim in essential_claims if claim not in claims]
|
|
||||||
|
|
||||||
if missing_claims:
|
|
||||||
logger.error("Missing essential claims: %s", missing_claims)
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def get_or_create_user(self, access_token, id_token, payload):
|
|
||||||
"""Return a User based on userinfo. Create a new user if no match is found."""
|
|
||||||
|
|
||||||
user_info = self.get_userinfo(access_token, id_token, payload)
|
|
||||||
|
|
||||||
if not self.verify_claims(user_info):
|
|
||||||
raise SuspiciousOperation("Claims verification failed.")
|
|
||||||
|
|
||||||
sub = user_info["sub"]
|
|
||||||
email = user_info.get("email")
|
|
||||||
|
|
||||||
# Get user's full name from OIDC fields defined in settings
|
|
||||||
full_name = self.compute_full_name(user_info)
|
|
||||||
short_name = user_info.get(settings.USER_OIDC_FIELD_TO_SHORTNAME)
|
|
||||||
|
|
||||||
claims = {
|
|
||||||
"email": email,
|
|
||||||
"full_name": full_name,
|
|
||||||
"short_name": short_name,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def get_existing_user(self, sub, email):
|
||||||
|
"""Fetch existing user by sub or email."""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user = User.objects.get_user_by_sub_or_email(sub, email)
|
return self.UserModel.objects.get_user_by_sub_or_email(sub, email)
|
||||||
except DuplicateEmailError as err:
|
except DuplicateEmailError as err:
|
||||||
raise SuspiciousOperation(err.message) from err
|
raise SuspiciousOperation(err.message) from err
|
||||||
|
|
||||||
if user:
|
|
||||||
if not user.is_active:
|
|
||||||
raise SuspiciousOperation(_("User account is disabled"))
|
|
||||||
self.update_user_if_needed(user, claims)
|
|
||||||
elif self.get_settings("OIDC_CREATE_USER", True):
|
|
||||||
user = User.objects.create(sub=sub, password="!", **claims) # noqa: S106
|
|
||||||
|
|
||||||
return user
|
|
||||||
|
|
||||||
def compute_full_name(self, user_info):
|
|
||||||
"""Compute user's full name based on OIDC fields in settings."""
|
|
||||||
name_fields = settings.USER_OIDC_FIELDS_TO_FULLNAME
|
|
||||||
full_name = " ".join(
|
|
||||||
user_info[field] for field in name_fields if user_info.get(field)
|
|
||||||
)
|
|
||||||
return full_name or None
|
|
||||||
|
|
||||||
def update_user_if_needed(self, user, claims):
|
|
||||||
"""Update user claims if they have changed."""
|
|
||||||
has_changed = any(
|
|
||||||
value and value != getattr(user, key) for key, value in claims.items()
|
|
||||||
)
|
|
||||||
if has_changed:
|
|
||||||
updated_claims = {key: value for key, value in claims.items() if value}
|
|
||||||
self.UserModel.objects.filter(id=user.id).update(**updated_claims)
|
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
"""Authentication URLs for the People core app."""
|
|
||||||
|
|
||||||
from django.urls import path
|
|
||||||
|
|
||||||
from mozilla_django_oidc.urls import urlpatterns as mozzila_oidc_urls
|
|
||||||
|
|
||||||
from .views import OIDCLogoutCallbackView, OIDCLogoutView
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
# Override the default 'logout/' path from Mozilla Django OIDC with our custom view.
|
|
||||||
path("logout/", OIDCLogoutView.as_view(), name="oidc_logout_custom"),
|
|
||||||
path(
|
|
||||||
"logout-callback/",
|
|
||||||
OIDCLogoutCallbackView.as_view(),
|
|
||||||
name="oidc_logout_callback",
|
|
||||||
),
|
|
||||||
*mozzila_oidc_urls,
|
|
||||||
]
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
"""Authentication Views for the People core app."""
|
|
||||||
|
|
||||||
from urllib.parse import urlencode
|
|
||||||
|
|
||||||
from django.contrib import auth
|
|
||||||
from django.core.exceptions import SuspiciousOperation
|
|
||||||
from django.http import HttpResponseRedirect
|
|
||||||
from django.urls import reverse
|
|
||||||
from django.utils import crypto
|
|
||||||
|
|
||||||
from mozilla_django_oidc.utils import (
|
|
||||||
absolutify,
|
|
||||||
)
|
|
||||||
from mozilla_django_oidc.views import (
|
|
||||||
OIDCLogoutView as MozillaOIDCOIDCLogoutView,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class OIDCLogoutView(MozillaOIDCOIDCLogoutView):
|
|
||||||
"""Custom logout view for handling OpenID Connect (OIDC) logout flow.
|
|
||||||
|
|
||||||
Adds support for handling logout callbacks from the identity provider (OP)
|
|
||||||
by initiating the logout flow if the user has an active session.
|
|
||||||
|
|
||||||
The Django session is retained during the logout process to persist the 'state' OIDC parameter.
|
|
||||||
This parameter is crucial for maintaining the integrity of the logout flow between this call
|
|
||||||
and the subsequent callback.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def persist_state(request, state):
|
|
||||||
"""Persist the given 'state' parameter in the session's 'oidc_states' dictionary
|
|
||||||
|
|
||||||
This method is used to store the OIDC state parameter in the session, according to the
|
|
||||||
structure expected by Mozilla Django OIDC's 'add_state_and_verifier_and_nonce_to_session'
|
|
||||||
utility function.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if "oidc_states" not in request.session or not isinstance(
|
|
||||||
request.session["oidc_states"], dict
|
|
||||||
):
|
|
||||||
request.session["oidc_states"] = {}
|
|
||||||
|
|
||||||
request.session["oidc_states"][state] = {}
|
|
||||||
request.session.save()
|
|
||||||
|
|
||||||
def construct_oidc_logout_url(self, request):
|
|
||||||
"""Create the redirect URL for interfacing with the OIDC provider.
|
|
||||||
|
|
||||||
Retrieves the necessary parameters from the session and constructs the URL
|
|
||||||
required to initiate logout with the OpenID Connect provider.
|
|
||||||
|
|
||||||
If no ID token is found in the session, the logout flow will not be initiated,
|
|
||||||
and the method will return the default redirect URL.
|
|
||||||
|
|
||||||
The 'state' parameter is generated randomly and persisted in the session to ensure
|
|
||||||
its integrity during the subsequent callback.
|
|
||||||
"""
|
|
||||||
|
|
||||||
oidc_logout_endpoint = self.get_settings("OIDC_OP_LOGOUT_ENDPOINT")
|
|
||||||
|
|
||||||
if not oidc_logout_endpoint:
|
|
||||||
return self.redirect_url
|
|
||||||
|
|
||||||
reverse_url = reverse("oidc_logout_callback")
|
|
||||||
id_token = request.session.get("oidc_id_token", None)
|
|
||||||
|
|
||||||
if not id_token:
|
|
||||||
return self.redirect_url
|
|
||||||
|
|
||||||
query = {
|
|
||||||
"id_token_hint": id_token,
|
|
||||||
"state": crypto.get_random_string(self.get_settings("OIDC_STATE_SIZE", 32)),
|
|
||||||
"post_logout_redirect_uri": absolutify(request, reverse_url),
|
|
||||||
}
|
|
||||||
|
|
||||||
self.persist_state(request, query["state"])
|
|
||||||
|
|
||||||
return f"{oidc_logout_endpoint}?{urlencode(query)}"
|
|
||||||
|
|
||||||
def post(self, request):
|
|
||||||
"""Handle user logout.
|
|
||||||
|
|
||||||
If the user is not authenticated, redirects to the default logout URL.
|
|
||||||
Otherwise, constructs the OIDC logout URL and redirects the user to start
|
|
||||||
the logout process.
|
|
||||||
|
|
||||||
If the user is redirected to the default logout URL, ensure her Django session
|
|
||||||
is terminated.
|
|
||||||
"""
|
|
||||||
|
|
||||||
logout_url = self.redirect_url
|
|
||||||
|
|
||||||
if request.user.is_authenticated:
|
|
||||||
logout_url = self.construct_oidc_logout_url(request)
|
|
||||||
|
|
||||||
# If the user is not redirected to the OIDC provider, ensure logout
|
|
||||||
if logout_url == self.redirect_url:
|
|
||||||
auth.logout(request)
|
|
||||||
|
|
||||||
return HttpResponseRedirect(logout_url)
|
|
||||||
|
|
||||||
|
|
||||||
class OIDCLogoutCallbackView(MozillaOIDCOIDCLogoutView):
|
|
||||||
"""Custom view for handling the logout callback from the OpenID Connect (OIDC) provider.
|
|
||||||
|
|
||||||
Handles the callback after logout from the identity provider (OP).
|
|
||||||
Verifies the state parameter and performs necessary logout actions.
|
|
||||||
|
|
||||||
The Django session is maintained during the logout process to ensure the integrity
|
|
||||||
of the logout flow initiated in the previous step.
|
|
||||||
"""
|
|
||||||
|
|
||||||
http_method_names = ["get"]
|
|
||||||
|
|
||||||
def get(self, request):
|
|
||||||
"""Handle the logout callback.
|
|
||||||
|
|
||||||
If the user is not authenticated, redirects to the default logout URL.
|
|
||||||
Otherwise, verifies the state parameter and performs necessary logout actions.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if not request.user.is_authenticated:
|
|
||||||
return HttpResponseRedirect(self.redirect_url)
|
|
||||||
|
|
||||||
state = request.GET.get("state")
|
|
||||||
|
|
||||||
if state not in request.session.get("oidc_states", {}):
|
|
||||||
msg = "OIDC callback state not found in session `oidc_states`!"
|
|
||||||
raise SuspiciousOperation(msg)
|
|
||||||
|
|
||||||
del request.session["oidc_states"][state]
|
|
||||||
request.session.save()
|
|
||||||
|
|
||||||
auth.logout(request)
|
|
||||||
|
|
||||||
return HttpResponseRedirect(self.redirect_url)
|
|
||||||
117
src/backend/core/choices.py
Normal file
117
src/backend/core/choices.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
"""Declare and configure choices for Docs' core application."""
|
||||||
|
|
||||||
|
from django.db.models import TextChoices
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
|
class PriorityTextChoices(TextChoices):
|
||||||
|
"""
|
||||||
|
This class inherits from Django's TextChoices and provides a method to get the priority
|
||||||
|
of a given value based on its position in the class.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_priority(cls, role):
|
||||||
|
"""Returns the priority of the given role based on its order in the class."""
|
||||||
|
|
||||||
|
members = list(cls.__members__.values())
|
||||||
|
return members.index(role) + 1 if role in members else 0
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def max(cls, *roles):
|
||||||
|
"""
|
||||||
|
Return the highest-priority role among the given roles, using get_priority().
|
||||||
|
If no valid roles are provided, returns None.
|
||||||
|
"""
|
||||||
|
valid_roles = [role for role in roles if cls.get_priority(role) is not None]
|
||||||
|
if not valid_roles:
|
||||||
|
return None
|
||||||
|
return max(valid_roles, key=cls.get_priority)
|
||||||
|
|
||||||
|
|
||||||
|
class LinkRoleChoices(PriorityTextChoices):
|
||||||
|
"""Defines the possible roles a link can offer on a document."""
|
||||||
|
|
||||||
|
READER = "reader", _("Reader") # Can read
|
||||||
|
COMMENTER = "commenter", _("Commenter") # Can read and comment
|
||||||
|
EDITOR = "editor", _("Editor") # Can read and edit
|
||||||
|
|
||||||
|
|
||||||
|
class RoleChoices(PriorityTextChoices):
|
||||||
|
"""Defines the possible roles a user can have in a resource."""
|
||||||
|
|
||||||
|
READER = "reader", _("Reader") # Can read
|
||||||
|
COMMENTER = "commenter", _("Commenter") # Can read and comment
|
||||||
|
EDITOR = "editor", _("Editor") # Can read and edit
|
||||||
|
ADMIN = "administrator", _("Administrator") # Can read, edit, delete and share
|
||||||
|
OWNER = "owner", _("Owner")
|
||||||
|
|
||||||
|
|
||||||
|
PRIVILEGED_ROLES = [RoleChoices.ADMIN, RoleChoices.OWNER]
|
||||||
|
|
||||||
|
|
||||||
|
class LinkReachChoices(PriorityTextChoices):
|
||||||
|
"""Defines types of access for links"""
|
||||||
|
|
||||||
|
RESTRICTED = (
|
||||||
|
"restricted",
|
||||||
|
_("Restricted"),
|
||||||
|
) # Only users with a specific access can read/edit the document
|
||||||
|
AUTHENTICATED = (
|
||||||
|
"authenticated",
|
||||||
|
_("Authenticated"),
|
||||||
|
) # Any authenticated user can access the document
|
||||||
|
PUBLIC = "public", _("Public") # Even anonymous users can access the document
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_select_options(cls, link_reach, link_role):
|
||||||
|
"""
|
||||||
|
Determines the valid select options for link reach and link role depending on the
|
||||||
|
ancestors' link reach/role given as arguments.
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping possible reach levels to their corresponding possible roles.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
reach: [
|
||||||
|
role
|
||||||
|
for role in LinkRoleChoices.values
|
||||||
|
if LinkRoleChoices.get_priority(role)
|
||||||
|
>= LinkRoleChoices.get_priority(link_role)
|
||||||
|
]
|
||||||
|
if reach != cls.RESTRICTED
|
||||||
|
else None
|
||||||
|
for reach in cls.values
|
||||||
|
if LinkReachChoices.get_priority(reach)
|
||||||
|
>= LinkReachChoices.get_priority(link_reach)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_equivalent_link_definition(ancestors_links):
|
||||||
|
"""
|
||||||
|
Return the (reach, role) pair with:
|
||||||
|
1. Highest reach
|
||||||
|
2. Highest role among links having that reach
|
||||||
|
"""
|
||||||
|
if not ancestors_links:
|
||||||
|
return {"link_reach": None, "link_role": None}
|
||||||
|
|
||||||
|
# 1) Find the highest reach
|
||||||
|
max_reach = max(
|
||||||
|
ancestors_links,
|
||||||
|
key=lambda link: LinkReachChoices.get_priority(link["link_reach"]),
|
||||||
|
)["link_reach"]
|
||||||
|
|
||||||
|
# 2) Among those, find the highest role (ignore role if RESTRICTED)
|
||||||
|
if max_reach == LinkReachChoices.RESTRICTED:
|
||||||
|
max_role = None
|
||||||
|
else:
|
||||||
|
max_role = max(
|
||||||
|
(
|
||||||
|
link["link_role"]
|
||||||
|
for link in ancestors_links
|
||||||
|
if link["link_reach"] == max_reach
|
||||||
|
),
|
||||||
|
key=LinkRoleChoices.get_priority,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"link_reach": max_reach, "link_role": max_role}
|
||||||
@@ -2,10 +2,27 @@
|
|||||||
Core application enums declaration
|
Core application enums declaration
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from django.conf import global_settings
|
import re
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
from django.conf import global_settings, settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
ATTACHMENTS_FOLDER = "attachments"
|
||||||
|
UUID_REGEX = (
|
||||||
|
r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"
|
||||||
|
)
|
||||||
|
FILE_EXT_REGEX = r"\.[a-zA-Z0-9]{1,10}"
|
||||||
|
MEDIA_STORAGE_URL_PATTERN = re.compile(
|
||||||
|
f"{settings.MEDIA_URL:s}(?P<pk>{UUID_REGEX:s})/"
|
||||||
|
f"(?P<attachment>{ATTACHMENTS_FOLDER:s}/{UUID_REGEX:s}(?:-unsafe)?{FILE_EXT_REGEX:s})$"
|
||||||
|
)
|
||||||
|
MEDIA_STORAGE_URL_EXTRACT = re.compile(
|
||||||
|
f"{settings.MEDIA_URL:s}({UUID_REGEX}/{ATTACHMENTS_FOLDER}/{UUID_REGEX}{FILE_EXT_REGEX})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# In Django's code base, `LANGUAGES` is set by default with all supported languages.
|
# In Django's code base, `LANGUAGES` is set by default with all supported languages.
|
||||||
# We can use it for the choice of languages which should not be limited to the few languages
|
# We can use it for the choice of languages which should not be limited to the few languages
|
||||||
# active in the app.
|
# active in the app.
|
||||||
@@ -22,3 +39,10 @@ class MoveNodePositionChoices(models.TextChoices):
|
|||||||
LAST_SIBLING = "last-sibling", _("Last sibling")
|
LAST_SIBLING = "last-sibling", _("Last sibling")
|
||||||
LEFT = "left", _("Left")
|
LEFT = "left", _("Left")
|
||||||
RIGHT = "right", _("Right")
|
RIGHT = "right", _("Right")
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentAttachmentStatus(StrEnum):
|
||||||
|
"""Defines the possible statuses for an attachment."""
|
||||||
|
|
||||||
|
PROCESSING = "processing"
|
||||||
|
READY = "ready"
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
# ruff: noqa: S311
|
|
||||||
"""
|
"""
|
||||||
Core application factories
|
Core application factories
|
||||||
"""
|
"""
|
||||||
@@ -13,12 +12,30 @@ from core import models
|
|||||||
|
|
||||||
fake = Faker()
|
fake = Faker()
|
||||||
|
|
||||||
|
YDOC_HELLO_WORLD_BASE64 = (
|
||||||
|
"AR717vLVDgAHAQ5kb2N1bWVudC1zdG9yZQMKYmxvY2tHcm91cAcA9e7y1Q4AAw5ibG9ja0NvbnRh"
|
||||||
|
"aW5lcgcA9e7y1Q4BAwdoZWFkaW5nBwD17vLVDgIGBgD17vLVDgMGaXRhbGljAnt9hPXu8tUOBAVI"
|
||||||
|
"ZWxsb4b17vLVDgkGaXRhbGljBG51bGwoAPXu8tUOAg10ZXh0QWxpZ25tZW50AXcEbGVmdCgA9e7y"
|
||||||
|
"1Q4CBWxldmVsAX0BKAD17vLVDgECaWQBdyQwNGQ2MjM0MS04MzI2LTQyMzYtYTA4My00ODdlMjZm"
|
||||||
|
"YWQyMzAoAPXu8tUOAQl0ZXh0Q29sb3IBdwdkZWZhdWx0KAD17vLVDgEPYmFja2dyb3VuZENvbG9y"
|
||||||
|
"AXcHZGVmYXVsdIf17vLVDgEDDmJsb2NrQ29udGFpbmVyBwD17vLVDhADDmJ1bGxldExpc3RJdGVt"
|
||||||
|
"BwD17vLVDhEGBAD17vLVDhIBd4b17vLVDhMEYm9sZAJ7fYT17vLVDhQCb3KG9e7y1Q4WBGJvbGQE"
|
||||||
|
"bnVsbIT17vLVDhcCbGQoAPXu8tUOEQ10ZXh0QWxpZ25tZW50AXcEbGVmdCgA9e7y1Q4QAmlkAXck"
|
||||||
|
"ZDM1MWUwNjgtM2U1NS00MjI2LThlYTUtYWJiMjYzMTk4ZTJhKAD17vLVDhAJdGV4dENvbG9yAXcH"
|
||||||
|
"ZGVmYXVsdCgA9e7y1Q4QD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHSH9e7y1Q4QAw5ibG9ja0Nv"
|
||||||
|
"bnRhaW5lcgcA9e7y1Q4eAwlwYXJhZ3JhcGgoAPXu8tUOHw10ZXh0QWxpZ25tZW50AXcEbGVmdCgA"
|
||||||
|
"9e7y1Q4eAmlkAXckODk3MDBjMDctZTBlMS00ZmUwLWFjYTItODQ5MzIwOWE3ZTQyKAD17vLVDh4J"
|
||||||
|
"dGV4dENvbG9yAXcHZGVmYXVsdCgA9e7y1Q4eD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQA"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class UserFactory(factory.django.DjangoModelFactory):
|
class UserFactory(factory.django.DjangoModelFactory):
|
||||||
"""A factory to random users for testing purposes."""
|
"""A factory to random users for testing purposes."""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.User
|
model = models.User
|
||||||
|
# Skip postgeneration save, no save is made in the postgeneration methods.
|
||||||
|
skip_postgeneration_save = True
|
||||||
|
|
||||||
sub = factory.Sequence(lambda n: f"user{n!s}")
|
sub = factory.Sequence(lambda n: f"user{n!s}")
|
||||||
email = factory.Faker("email")
|
email = factory.Faker("email")
|
||||||
@@ -75,7 +92,7 @@ class DocumentFactory(factory.django.DjangoModelFactory):
|
|||||||
|
|
||||||
title = factory.Sequence(lambda n: f"document{n}")
|
title = factory.Sequence(lambda n: f"document{n}")
|
||||||
excerpt = factory.Sequence(lambda n: f"excerpt{n}")
|
excerpt = factory.Sequence(lambda n: f"excerpt{n}")
|
||||||
content = factory.Sequence(lambda n: f"content{n}")
|
content = YDOC_HELLO_WORLD_BASE64
|
||||||
creator = factory.SubFactory(UserFactory)
|
creator = factory.SubFactory(UserFactory)
|
||||||
deleted_at = None
|
deleted_at = None
|
||||||
link_reach = factory.fuzzy.FuzzyChoice(
|
link_reach = factory.fuzzy.FuzzyChoice(
|
||||||
@@ -133,7 +150,7 @@ class DocumentFactory(factory.django.DjangoModelFactory):
|
|||||||
"""Add link traces to document from a given list of users."""
|
"""Add link traces to document from a given list of users."""
|
||||||
if create and extracted:
|
if create and extracted:
|
||||||
for item in extracted:
|
for item in extracted:
|
||||||
models.LinkTrace.objects.create(document=self, user=item)
|
models.LinkTrace.objects.update_or_create(document=self, user=item)
|
||||||
|
|
||||||
@factory.post_generation
|
@factory.post_generation
|
||||||
def favorited_by(self, create, extracted, **kwargs):
|
def favorited_by(self, create, extracted, **kwargs):
|
||||||
@@ -142,6 +159,15 @@ class DocumentFactory(factory.django.DjangoModelFactory):
|
|||||||
for item in extracted:
|
for item in extracted:
|
||||||
models.DocumentFavorite.objects.create(document=self, user=item)
|
models.DocumentFavorite.objects.create(document=self, user=item)
|
||||||
|
|
||||||
|
@factory.post_generation
|
||||||
|
def masked_by(self, create, extracted, **kwargs):
|
||||||
|
"""Mark document as masked by a list of users."""
|
||||||
|
if create and extracted:
|
||||||
|
for item in extracted:
|
||||||
|
models.LinkTrace.objects.update_or_create(
|
||||||
|
document=self, user=item, defaults={"is_masked": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class UserDocumentAccessFactory(factory.django.DjangoModelFactory):
|
class UserDocumentAccessFactory(factory.django.DjangoModelFactory):
|
||||||
"""Create fake document user accesses for testing."""
|
"""Create fake document user accesses for testing."""
|
||||||
@@ -165,6 +191,17 @@ class TeamDocumentAccessFactory(factory.django.DjangoModelFactory):
|
|||||||
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
|
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentAskForAccessFactory(factory.django.DjangoModelFactory):
|
||||||
|
"""Create fake document ask for access for testing."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.DocumentAskForAccess
|
||||||
|
|
||||||
|
document = factory.SubFactory(DocumentFactory)
|
||||||
|
user = factory.SubFactory(UserFactory)
|
||||||
|
role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices])
|
||||||
|
|
||||||
|
|
||||||
class TemplateFactory(factory.django.DjangoModelFactory):
|
class TemplateFactory(factory.django.DjangoModelFactory):
|
||||||
"""A factory to create templates"""
|
"""A factory to create templates"""
|
||||||
|
|
||||||
@@ -219,3 +256,49 @@ class InvitationFactory(factory.django.DjangoModelFactory):
|
|||||||
document = factory.SubFactory(DocumentFactory)
|
document = factory.SubFactory(DocumentFactory)
|
||||||
role = factory.fuzzy.FuzzyChoice([role[0] for role in models.RoleChoices.choices])
|
role = factory.fuzzy.FuzzyChoice([role[0] for role in models.RoleChoices.choices])
|
||||||
issuer = factory.SubFactory(UserFactory)
|
issuer = factory.SubFactory(UserFactory)
|
||||||
|
|
||||||
|
|
||||||
|
class ThreadFactory(factory.django.DjangoModelFactory):
|
||||||
|
"""A factory to create threads for a document"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.Thread
|
||||||
|
|
||||||
|
document = factory.SubFactory(DocumentFactory)
|
||||||
|
creator = factory.SubFactory(UserFactory)
|
||||||
|
|
||||||
|
|
||||||
|
class CommentFactory(factory.django.DjangoModelFactory):
|
||||||
|
"""A factory to create comments for a thread"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.Comment
|
||||||
|
|
||||||
|
thread = factory.SubFactory(ThreadFactory)
|
||||||
|
user = factory.SubFactory(UserFactory)
|
||||||
|
body = factory.Faker("text")
|
||||||
|
|
||||||
|
|
||||||
|
class ReactionFactory(factory.django.DjangoModelFactory):
|
||||||
|
"""A factory to create reactions for a comment"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.Reaction
|
||||||
|
|
||||||
|
comment = factory.SubFactory(CommentFactory)
|
||||||
|
emoji = "test"
|
||||||
|
|
||||||
|
@factory.post_generation
|
||||||
|
def users(self, create, extracted, **kwargs):
|
||||||
|
"""Add users to reaction from a given list of users or create one if not provided."""
|
||||||
|
if not create:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not extracted:
|
||||||
|
# the factory is being created, but no users were provided
|
||||||
|
user = UserFactory()
|
||||||
|
self.users.add(user)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Add the iterable of groups using bulk addition
|
||||||
|
self.users.add(*extracted)
|
||||||
|
|||||||
52
src/backend/core/malware_detection.py
Normal file
52
src/backend/core/malware_detection.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"""Malware detection callbacks"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from django.core.files.storage import default_storage
|
||||||
|
|
||||||
|
from lasuite.malware_detection.enums import ReportStatus
|
||||||
|
|
||||||
|
from core.enums import DocumentAttachmentStatus
|
||||||
|
from core.models import Document
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
security_logger = logging.getLogger("docs.security")
|
||||||
|
|
||||||
|
|
||||||
|
def malware_detection_callback(file_path, status, error_info, **kwargs):
|
||||||
|
"""Malware detection callback"""
|
||||||
|
|
||||||
|
if status == ReportStatus.SAFE:
|
||||||
|
logger.info("File %s is safe", file_path)
|
||||||
|
# Get existing metadata
|
||||||
|
s3_client = default_storage.connection.meta.client
|
||||||
|
bucket_name = default_storage.bucket_name
|
||||||
|
head_resp = s3_client.head_object(Bucket=bucket_name, Key=file_path)
|
||||||
|
metadata = head_resp.get("Metadata", {})
|
||||||
|
metadata.update({"status": DocumentAttachmentStatus.READY})
|
||||||
|
# Update status in metadata
|
||||||
|
s3_client.copy_object(
|
||||||
|
Bucket=bucket_name,
|
||||||
|
CopySource={"Bucket": bucket_name, "Key": file_path},
|
||||||
|
Key=file_path,
|
||||||
|
ContentType=head_resp.get("ContentType"),
|
||||||
|
Metadata=metadata,
|
||||||
|
MetadataDirective="REPLACE",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
document_id = kwargs.get("document_id")
|
||||||
|
security_logger.warning(
|
||||||
|
"File %s for document %s is infected with malware. Error info: %s",
|
||||||
|
file_path,
|
||||||
|
document_id,
|
||||||
|
error_info,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove the file from the document and change the status to unsafe
|
||||||
|
document = Document.objects.get(pk=document_id)
|
||||||
|
document.attachments.remove(file_path)
|
||||||
|
document.save(update_fields=["attachments"])
|
||||||
|
|
||||||
|
# Delete the file from the storage
|
||||||
|
default_storage.delete(file_path)
|
||||||
21
src/backend/core/middleware.py
Normal file
21
src/backend/core/middleware.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"""Force session creation for all requests."""
|
||||||
|
|
||||||
|
|
||||||
|
class ForceSessionMiddleware:
|
||||||
|
"""
|
||||||
|
Force session creation for unauthenticated users.
|
||||||
|
Must be used after Authentication middleware.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, get_response):
|
||||||
|
"""Initialize the middleware."""
|
||||||
|
self.get_response = get_response
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
"""Force session creation for unauthenticated users."""
|
||||||
|
|
||||||
|
if not request.user.is_authenticated and request.session.session_key is None:
|
||||||
|
request.session.create()
|
||||||
|
|
||||||
|
response = self.get_response(request)
|
||||||
|
return response
|
||||||
@@ -504,7 +504,7 @@ class Migration(migrations.Migration):
|
|||||||
migrations.AddConstraint(
|
migrations.AddConstraint(
|
||||||
model_name="documentaccess",
|
model_name="documentaccess",
|
||||||
constraint=models.CheckConstraint(
|
constraint=models.CheckConstraint(
|
||||||
check=models.Q(
|
condition=models.Q(
|
||||||
models.Q(("team", ""), ("user__isnull", False)),
|
models.Q(("team", ""), ("user__isnull", False)),
|
||||||
models.Q(("team__gt", ""), ("user__isnull", True)),
|
models.Q(("team__gt", ""), ("user__isnull", True)),
|
||||||
_connector="OR",
|
_connector="OR",
|
||||||
@@ -540,7 +540,7 @@ class Migration(migrations.Migration):
|
|||||||
migrations.AddConstraint(
|
migrations.AddConstraint(
|
||||||
model_name="templateaccess",
|
model_name="templateaccess",
|
||||||
constraint=models.CheckConstraint(
|
constraint=models.CheckConstraint(
|
||||||
check=models.Q(
|
condition=models.Q(
|
||||||
models.Q(("team", ""), ("user__isnull", False)),
|
models.Q(("team", ""), ("user__isnull", False)),
|
||||||
models.Q(("team__gt", ""), ("user__isnull", True)),
|
models.Q(("team__gt", ""), ("user__isnull", True)),
|
||||||
_connector="OR",
|
_connector="OR",
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Generated by Django 5.1.4 on 2025-01-18 11:53
|
||||||
|
import re
|
||||||
|
|
||||||
|
import django.contrib.postgres.fields
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.core.files.storage import default_storage
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
|
||||||
|
import core.models
|
||||||
|
from core.utils import extract_attachments
|
||||||
|
|
||||||
|
|
||||||
|
def populate_attachments_on_all_documents(apps, schema_editor):
|
||||||
|
"""Populate "attachments" field on all existing documents in the database."""
|
||||||
|
Document = apps.get_model("core", "Document")
|
||||||
|
|
||||||
|
for document in Document.objects.all():
|
||||||
|
try:
|
||||||
|
response = default_storage.connection.meta.client.get_object(
|
||||||
|
Bucket=default_storage.bucket_name, Key=f"{document.pk!s}/file"
|
||||||
|
)
|
||||||
|
except (FileNotFoundError, ClientError):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
content = response["Body"].read().decode("utf-8")
|
||||||
|
document.attachments = extract_attachments(content)
|
||||||
|
document.save(update_fields=["attachments"])
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("core", "0019_alter_user_language_default_to_null"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# v2.0.0 was released so we can now remove BC field "is_public"
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="document",
|
||||||
|
name="is_public",
|
||||||
|
),
|
||||||
|
migrations.AlterModelManagers(
|
||||||
|
name="user",
|
||||||
|
managers=[
|
||||||
|
("objects", core.models.UserManager()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="document",
|
||||||
|
name="attachments",
|
||||||
|
field=django.contrib.postgres.fields.ArrayField(
|
||||||
|
base_field=models.CharField(max_length=255),
|
||||||
|
blank=True,
|
||||||
|
default=list,
|
||||||
|
editable=False,
|
||||||
|
null=True,
|
||||||
|
size=None,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="document",
|
||||||
|
name="duplicated_from",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
editable=False,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="duplicates",
|
||||||
|
to="core.document",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(
|
||||||
|
populate_attachments_on_all_documents,
|
||||||
|
reverse_code=migrations.RunPython.noop,
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from django.contrib.postgres.operations import UnaccentExtension
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("core", "0020_remove_is_public_add_field_attachments_and_duplicated_from"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [UnaccentExtension()]
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# Generated by Django 5.2.3 on 2025-06-18 10:02
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("core", "0021_activate_unaccent_extension"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="DocumentAskForAccess",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.UUIDField(
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
help_text="primary key for the record as UUID",
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="id",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"created_at",
|
||||||
|
models.DateTimeField(
|
||||||
|
auto_now_add=True,
|
||||||
|
help_text="date and time at which a record was created",
|
||||||
|
verbose_name="created on",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"updated_at",
|
||||||
|
models.DateTimeField(
|
||||||
|
auto_now=True,
|
||||||
|
help_text="date and time at which a record was last updated",
|
||||||
|
verbose_name="updated on",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"role",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("reader", "Reader"),
|
||||||
|
("editor", "Editor"),
|
||||||
|
("administrator", "Administrator"),
|
||||||
|
("owner", "Owner"),
|
||||||
|
],
|
||||||
|
default="reader",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"document",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="ask_for_accesses",
|
||||||
|
to="core.document",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="ask_for_accesses",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Document ask for access",
|
||||||
|
"verbose_name_plural": "Document ask for accesses",
|
||||||
|
"db_table": "impress_document_ask_for_access",
|
||||||
|
"constraints": [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=("user", "document"),
|
||||||
|
name="unique_document_ask_for_access_user",
|
||||||
|
violation_error_message="This user has already asked for access to this document.",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 5.1.7 on 2025-03-14 14:03
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("core", "0022_alter_user_language_documentaskforaccess"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="document",
|
||||||
|
name="has_deleted_children",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Generated by Django 5.2.3 on 2025-07-13 08:22
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
import core.validators
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("core", "0023_remove_document_is_public_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="linktrace",
|
||||||
|
name="is_masked",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="language",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
choices=[
|
||||||
|
("en-us", "English"),
|
||||||
|
("fr-fr", "Français"),
|
||||||
|
("de-de", "Deutsch"),
|
||||||
|
("nl-nl", "Nederlands"),
|
||||||
|
("es-es", "Español"),
|
||||||
|
],
|
||||||
|
default=None,
|
||||||
|
help_text="The language in which the user wants to see the interface.",
|
||||||
|
max_length=10,
|
||||||
|
null=True,
|
||||||
|
verbose_name="language",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="sub",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Required. 255 characters or fewer. ASCII characters only.",
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
unique=True,
|
||||||
|
validators=[core.validators.sub_validator],
|
||||||
|
verbose_name="sub",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
19
src/backend/core/migrations/0025_alter_user_short_name.py
Normal file
19
src/backend/core/migrations/0025_alter_user_short_name.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-10-22 06:12
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("core", "0024_add_is_masked_field_to_link_trace"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="short_name",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True, max_length=100, null=True, verbose_name="short name"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
275
src/backend/core/migrations/0026_comments.py
Normal file
275
src/backend/core/migrations/0026_comments.py
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
# Generated by Django 5.2.6 on 2025-09-16 08:59
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("core", "0025_alter_user_short_name"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="document",
|
||||||
|
name="link_role",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("reader", "Reader"),
|
||||||
|
("commenter", "Commenter"),
|
||||||
|
("editor", "Editor"),
|
||||||
|
],
|
||||||
|
default="reader",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="documentaccess",
|
||||||
|
name="role",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("reader", "Reader"),
|
||||||
|
("commenter", "Commenter"),
|
||||||
|
("editor", "Editor"),
|
||||||
|
("administrator", "Administrator"),
|
||||||
|
("owner", "Owner"),
|
||||||
|
],
|
||||||
|
default="reader",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="documentaskforaccess",
|
||||||
|
name="role",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("reader", "Reader"),
|
||||||
|
("commenter", "Commenter"),
|
||||||
|
("editor", "Editor"),
|
||||||
|
("administrator", "Administrator"),
|
||||||
|
("owner", "Owner"),
|
||||||
|
],
|
||||||
|
default="reader",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="invitation",
|
||||||
|
name="role",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("reader", "Reader"),
|
||||||
|
("commenter", "Commenter"),
|
||||||
|
("editor", "Editor"),
|
||||||
|
("administrator", "Administrator"),
|
||||||
|
("owner", "Owner"),
|
||||||
|
],
|
||||||
|
default="reader",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="templateaccess",
|
||||||
|
name="role",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("reader", "Reader"),
|
||||||
|
("commenter", "Commenter"),
|
||||||
|
("editor", "Editor"),
|
||||||
|
("administrator", "Administrator"),
|
||||||
|
("owner", "Owner"),
|
||||||
|
],
|
||||||
|
default="reader",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Thread",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.UUIDField(
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
help_text="primary key for the record as UUID",
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="id",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"created_at",
|
||||||
|
models.DateTimeField(
|
||||||
|
auto_now_add=True,
|
||||||
|
help_text="date and time at which a record was created",
|
||||||
|
verbose_name="created on",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"updated_at",
|
||||||
|
models.DateTimeField(
|
||||||
|
auto_now=True,
|
||||||
|
help_text="date and time at which a record was last updated",
|
||||||
|
verbose_name="updated on",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("resolved", models.BooleanField(default=False)),
|
||||||
|
("resolved_at", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("metadata", models.JSONField(blank=True, default=dict)),
|
||||||
|
(
|
||||||
|
"creator",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="threads",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"document",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="threads",
|
||||||
|
to="core.document",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"resolved_by",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="resolved_threads",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Thread",
|
||||||
|
"verbose_name_plural": "Threads",
|
||||||
|
"db_table": "impress_thread",
|
||||||
|
"ordering": ("-created_at",),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Comment",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.UUIDField(
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
help_text="primary key for the record as UUID",
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="id",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"created_at",
|
||||||
|
models.DateTimeField(
|
||||||
|
auto_now_add=True,
|
||||||
|
help_text="date and time at which a record was created",
|
||||||
|
verbose_name="created on",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"updated_at",
|
||||||
|
models.DateTimeField(
|
||||||
|
auto_now=True,
|
||||||
|
help_text="date and time at which a record was last updated",
|
||||||
|
verbose_name="updated on",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("body", models.JSONField()),
|
||||||
|
("metadata", models.JSONField(blank=True, default=dict)),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="thread_comment",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"thread",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="comments",
|
||||||
|
to="core.thread",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Comment",
|
||||||
|
"verbose_name_plural": "Comments",
|
||||||
|
"db_table": "impress_comment",
|
||||||
|
"ordering": ("created_at",),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Reaction",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.UUIDField(
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
help_text="primary key for the record as UUID",
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="id",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"created_at",
|
||||||
|
models.DateTimeField(
|
||||||
|
auto_now_add=True,
|
||||||
|
help_text="date and time at which a record was created",
|
||||||
|
verbose_name="created on",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"updated_at",
|
||||||
|
models.DateTimeField(
|
||||||
|
auto_now=True,
|
||||||
|
help_text="date and time at which a record was last updated",
|
||||||
|
verbose_name="updated on",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("emoji", models.CharField(max_length=32)),
|
||||||
|
(
|
||||||
|
"comment",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="reactions",
|
||||||
|
to="core.comment",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"users",
|
||||||
|
models.ManyToManyField(
|
||||||
|
related_name="reactions", to=settings.AUTH_USER_MODEL
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Reaction",
|
||||||
|
"verbose_name_plural": "Reactions",
|
||||||
|
"db_table": "impress_comment_reaction",
|
||||||
|
"constraints": [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=("comment", "emoji"),
|
||||||
|
name="unique_comment_emoji",
|
||||||
|
violation_error_message="This emoji has already been reacted to this comment.",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,8 @@ from core import enums
|
|||||||
|
|
||||||
AI_ACTIONS = {
|
AI_ACTIONS = {
|
||||||
"prompt": (
|
"prompt": (
|
||||||
"Answer the prompt in markdown format. "
|
"Answer the prompt using markdown formatting for structure and emphasis. "
|
||||||
|
"Return the content directly without wrapping it in code blocks or markdown delimiters. "
|
||||||
"Preserve the language and markdown formatting. "
|
"Preserve the language and markdown formatting. "
|
||||||
"Do not provide any other information. "
|
"Do not provide any other information. "
|
||||||
"Preserve the language."
|
"Preserve the language."
|
||||||
@@ -44,7 +45,7 @@ AI_ACTIONS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
AI_TRANSLATE = (
|
AI_TRANSLATE = (
|
||||||
"Keep the same html stucture and formatting. "
|
"Keep the same html structure and formatting. "
|
||||||
"Translate the content in the html to the specified language {language:s}. "
|
"Translate the content in the html to the specified language {language:s}. "
|
||||||
"Check the translation for accuracy and make any necessary corrections. "
|
"Check the translation for accuracy and make any necessary corrections. "
|
||||||
"Do not provide any other information."
|
"Do not provide any other information."
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class CollaborationService:
|
|||||||
def reset_connections(self, room, user_id=None):
|
def reset_connections(self, room, user_id=None):
|
||||||
"""
|
"""
|
||||||
Reset connections of a room in the collaboration server.
|
Reset connections of a room in the collaboration server.
|
||||||
Reseting a connection means that the user will be disconnected and will
|
Resetting a connection means that the user will be disconnected and will
|
||||||
have to reconnect to the collaboration server, with updated rights.
|
have to reconnect to the collaboration server, with updated rights.
|
||||||
"""
|
"""
|
||||||
endpoint = "reset-connections"
|
endpoint = "reset-connections"
|
||||||
@@ -41,3 +41,35 @@ class CollaborationService:
|
|||||||
f"Failed to notify WebSocket server. Status code: {response.status_code}, "
|
f"Failed to notify WebSocket server. Status code: {response.status_code}, "
|
||||||
f"Response: {response.text}"
|
f"Response: {response.text}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_document_connection_info(self, room, session_key):
|
||||||
|
"""
|
||||||
|
Get the connection info for a document.
|
||||||
|
"""
|
||||||
|
endpoint = "get-connections"
|
||||||
|
querystring = {
|
||||||
|
"room": room,
|
||||||
|
"sessionKey": session_key,
|
||||||
|
}
|
||||||
|
endpoint_url = f"{settings.COLLABORATION_API_URL}{endpoint}/"
|
||||||
|
|
||||||
|
headers = {"Authorization": settings.COLLABORATION_SERVER_SECRET}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(
|
||||||
|
endpoint_url, headers=headers, params=querystring, timeout=10
|
||||||
|
)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
raise requests.HTTPError("Failed to get document connection info.") from e
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
result = response.json()
|
||||||
|
return result.get("count", 0), result.get("exists", False)
|
||||||
|
|
||||||
|
if response.status_code == 404:
|
||||||
|
return 0, False
|
||||||
|
|
||||||
|
raise requests.HTTPError(
|
||||||
|
f"Failed to get document connection info. Status code: {response.status_code}, "
|
||||||
|
f"Response: {response.text}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"""Converter services."""
|
"""Y-Provider API services."""
|
||||||
|
|
||||||
|
from base64 import b64encode
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
@@ -17,14 +19,6 @@ class ServiceUnavailableError(ConversionError):
|
|||||||
"""Raised when the conversion service is unavailable."""
|
"""Raised when the conversion service is unavailable."""
|
||||||
|
|
||||||
|
|
||||||
class InvalidResponseError(ConversionError):
|
|
||||||
"""Raised when the conversion service returns an invalid response."""
|
|
||||||
|
|
||||||
|
|
||||||
class MissingContentError(ConversionError):
|
|
||||||
"""Raised when the response is missing required content."""
|
|
||||||
|
|
||||||
|
|
||||||
class YdocConverter:
|
class YdocConverter:
|
||||||
"""Service class for conversion-related operations."""
|
"""Service class for conversion-related operations."""
|
||||||
|
|
||||||
@@ -32,47 +26,47 @@ class YdocConverter:
|
|||||||
def auth_header(self):
|
def auth_header(self):
|
||||||
"""Build microservice authentication header."""
|
"""Build microservice authentication header."""
|
||||||
# Note: Yprovider microservice accepts only raw token, which is not recommended
|
# Note: Yprovider microservice accepts only raw token, which is not recommended
|
||||||
return settings.Y_PROVIDER_API_KEY
|
return f"Bearer {settings.Y_PROVIDER_API_KEY}"
|
||||||
|
|
||||||
def convert_markdown(self, text):
|
def _request(self, url, data, content_type, accept):
|
||||||
|
"""Make a request to the Y-Provider API."""
|
||||||
|
response = requests.post(
|
||||||
|
url,
|
||||||
|
data=data,
|
||||||
|
headers={
|
||||||
|
"Authorization": self.auth_header,
|
||||||
|
"Content-Type": content_type,
|
||||||
|
"Accept": accept,
|
||||||
|
},
|
||||||
|
timeout=settings.CONVERSION_API_TIMEOUT,
|
||||||
|
verify=settings.CONVERSION_API_SECURE,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response
|
||||||
|
|
||||||
|
def convert(
|
||||||
|
self, text, content_type="text/markdown", accept="application/vnd.yjs.doc"
|
||||||
|
):
|
||||||
"""Convert a Markdown text into our internal format using an external microservice."""
|
"""Convert a Markdown text into our internal format using an external microservice."""
|
||||||
|
|
||||||
if not text:
|
if not text:
|
||||||
raise ValidationError("Input text cannot be empty")
|
raise ValidationError("Input text cannot be empty")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.post(
|
response = self._request(
|
||||||
f"{settings.Y_PROVIDER_API_BASE_URL}{settings.CONVERSION_API_ENDPOINT}/",
|
f"{settings.Y_PROVIDER_API_BASE_URL}{settings.CONVERSION_API_ENDPOINT}/",
|
||||||
json={
|
text,
|
||||||
"content": text,
|
content_type,
|
||||||
},
|
accept,
|
||||||
headers={
|
|
||||||
"Authorization": self.auth_header,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
timeout=settings.CONVERSION_API_TIMEOUT,
|
|
||||||
verify=settings.CONVERSION_API_SECURE,
|
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
if accept == "application/vnd.yjs.doc":
|
||||||
conversion_response = response.json()
|
return b64encode(response.content).decode("utf-8")
|
||||||
|
if accept in {"text/markdown", "text/html"}:
|
||||||
|
return response.text
|
||||||
|
if accept == "application/json":
|
||||||
|
return response.json()
|
||||||
|
raise ValidationError("Unsupported format")
|
||||||
except requests.RequestException as err:
|
except requests.RequestException as err:
|
||||||
raise ServiceUnavailableError(
|
raise ServiceUnavailableError(
|
||||||
"Failed to connect to conversion service",
|
"Failed to connect to conversion service",
|
||||||
) from err
|
) from err
|
||||||
|
|
||||||
except ValueError as err:
|
|
||||||
raise InvalidResponseError(
|
|
||||||
"Could not parse conversion service response"
|
|
||||||
) from err
|
|
||||||
|
|
||||||
try:
|
|
||||||
document_content = conversion_response[
|
|
||||||
settings.CONVERSION_API_CONTENT_FIELD
|
|
||||||
]
|
|
||||||
except KeyError as err:
|
|
||||||
raise MissingContentError(
|
|
||||||
f"Response missing required field: {settings.CONVERSION_API_CONTENT_FIELD}"
|
|
||||||
) from err
|
|
||||||
|
|
||||||
return document_content
|
|
||||||
|
|||||||
0
src/backend/core/tasks/__init__.py
Normal file
0
src/backend/core/tasks/__init__.py
Normal file
24
src/backend/core/tasks/mail.py
Normal file
24
src/backend/core/tasks/mail.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""Send mail using celery task."""
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from core import models
|
||||||
|
|
||||||
|
from impress.celery_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.task
|
||||||
|
def send_ask_for_access_mail(ask_for_access_id):
|
||||||
|
"""Send mail using celery task."""
|
||||||
|
# Send email to document owners/admins
|
||||||
|
ask_for_access = models.DocumentAskForAccess.objects.get(id=ask_for_access_id)
|
||||||
|
owner_admin_accesses = models.DocumentAccess.objects.filter(
|
||||||
|
document=ask_for_access.document, role__in=models.PRIVILEGED_ROLES
|
||||||
|
).select_related("user")
|
||||||
|
|
||||||
|
for access in owner_admin_accesses:
|
||||||
|
if access.user and access.user.email:
|
||||||
|
ask_for_access.send_ask_for_access_email(
|
||||||
|
access.user.email,
|
||||||
|
access.user.language or settings.LANGUAGE_CODE,
|
||||||
|
)
|
||||||
@@ -2,14 +2,14 @@
|
|||||||
|
|
||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
from logging import Logger
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
from django.core.exceptions import SuspiciousOperation
|
from django.core.exceptions import SuspiciousOperation
|
||||||
from django.test.utils import override_settings
|
from django.test.utils import override_settings
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import responses
|
import responses
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from lasuite.oidc_login.backends import get_oidc_refresh_token
|
||||||
|
|
||||||
from core import models
|
from core import models
|
||||||
from core.authentication.backends import OIDCAuthenticationBackend
|
from core.authentication.backends import OIDCAuthenticationBackend
|
||||||
@@ -57,7 +57,7 @@ def test_authentication_getter_existing_user_via_email(
|
|||||||
|
|
||||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||||
|
|
||||||
with django_assert_num_queries(2):
|
with django_assert_num_queries(4): # user by sub, user by mail, update sub
|
||||||
user = klass.get_or_create_user(
|
user = klass.get_or_create_user(
|
||||||
access_token="test-token", id_token=None, payload=None
|
access_token="test-token", id_token=None, payload=None
|
||||||
)
|
)
|
||||||
@@ -214,7 +214,7 @@ def test_authentication_getter_existing_user_change_fields_sub(
|
|||||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||||
|
|
||||||
# One and only one additional update query when a field has changed
|
# One and only one additional update query when a field has changed
|
||||||
with django_assert_num_queries(2):
|
with django_assert_num_queries(3):
|
||||||
authenticated_user = klass.get_or_create_user(
|
authenticated_user = klass.get_or_create_user(
|
||||||
access_token="test-token", id_token=None, payload=None
|
access_token="test-token", id_token=None, payload=None
|
||||||
)
|
)
|
||||||
@@ -256,7 +256,7 @@ def test_authentication_getter_existing_user_change_fields_email(
|
|||||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||||
|
|
||||||
# One and only one additional update query when a field has changed
|
# One and only one additional update query when a field has changed
|
||||||
with django_assert_num_queries(3):
|
with django_assert_num_queries(4):
|
||||||
authenticated_user = klass.get_or_create_user(
|
authenticated_user = klass.get_or_create_user(
|
||||||
access_token="test-token", id_token=None, payload=None
|
access_token="test-token", id_token=None, payload=None
|
||||||
)
|
)
|
||||||
@@ -288,7 +288,7 @@ def test_authentication_getter_new_user_no_email(monkeypatch):
|
|||||||
assert user.email is None
|
assert user.email is None
|
||||||
assert user.full_name is None
|
assert user.full_name is None
|
||||||
assert user.short_name is None
|
assert user.short_name is None
|
||||||
assert user.password == "!"
|
assert user.has_usable_password() is False
|
||||||
assert models.User.objects.count() == 1
|
assert models.User.objects.count() == 1
|
||||||
|
|
||||||
|
|
||||||
@@ -315,7 +315,7 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
|
|||||||
assert user.email == email
|
assert user.email == email
|
||||||
assert user.full_name == "John Doe"
|
assert user.full_name == "John Doe"
|
||||||
assert user.short_name == "John"
|
assert user.short_name == "John"
|
||||||
assert user.password == "!"
|
assert user.has_usable_password() is False
|
||||||
assert models.User.objects.count() == 1
|
assert models.User.objects.count() == 1
|
||||||
|
|
||||||
|
|
||||||
@@ -345,11 +345,15 @@ def test_authentication_get_userinfo_json_response():
|
|||||||
|
|
||||||
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
|
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
|
||||||
@responses.activate
|
@responses.activate
|
||||||
def test_authentication_get_userinfo_token_response(monkeypatch):
|
def test_authentication_get_userinfo_token_response(monkeypatch, settings):
|
||||||
"""Test get_userinfo method with a token response."""
|
"""Test get_userinfo method with a token response."""
|
||||||
|
settings.OIDC_RP_SIGN_ALGO = "HS256" # disable JWKS URL call
|
||||||
responses.add(
|
responses.add(
|
||||||
responses.GET, re.compile(r".*/userinfo"), body="fake.jwt.token", status=200
|
responses.GET,
|
||||||
|
re.compile(r".*/userinfo"),
|
||||||
|
body="fake.jwt.token",
|
||||||
|
status=200,
|
||||||
|
content_type="application/jwt",
|
||||||
)
|
)
|
||||||
|
|
||||||
def mock_verify_token(self, token): # pylint: disable=unused-argument
|
def mock_verify_token(self, token): # pylint: disable=unused-argument
|
||||||
@@ -371,21 +375,25 @@ def test_authentication_get_userinfo_token_response(monkeypatch):
|
|||||||
|
|
||||||
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
|
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
|
||||||
@responses.activate
|
@responses.activate
|
||||||
def test_authentication_get_userinfo_invalid_response():
|
def test_authentication_get_userinfo_invalid_response(settings):
|
||||||
"""
|
"""
|
||||||
Test get_userinfo method with an invalid JWT response that
|
Test get_userinfo method with an invalid JWT response that
|
||||||
causes verify_token to raise an error.
|
causes verify_token to raise an error.
|
||||||
"""
|
"""
|
||||||
|
settings.OIDC_RP_SIGN_ALGO = "HS256" # disable JWKS URL call
|
||||||
responses.add(
|
responses.add(
|
||||||
responses.GET, re.compile(r".*/userinfo"), body="fake.jwt.token", status=200
|
responses.GET,
|
||||||
|
re.compile(r".*/userinfo"),
|
||||||
|
body="fake.jwt.token",
|
||||||
|
status=200,
|
||||||
|
content_type="application/jwt",
|
||||||
)
|
)
|
||||||
|
|
||||||
oidc_backend = OIDCAuthenticationBackend()
|
oidc_backend = OIDCAuthenticationBackend()
|
||||||
|
|
||||||
with pytest.raises(
|
with pytest.raises(
|
||||||
SuspiciousOperation,
|
SuspiciousOperation,
|
||||||
match="Invalid response format or token verification failed",
|
match="User info response was not valid JWT",
|
||||||
):
|
):
|
||||||
oidc_backend.get_userinfo("fake_access_token", None, None)
|
oidc_backend.get_userinfo("fake_access_token", None, None)
|
||||||
|
|
||||||
@@ -450,100 +458,54 @@ def test_authentication_getter_existing_disabled_user_via_email(
|
|||||||
assert models.User.objects.count() == 1
|
assert models.User.objects.count() == 1
|
||||||
|
|
||||||
|
|
||||||
# Essential claims
|
@responses.activate
|
||||||
|
def test_authentication_session_tokens(
|
||||||
|
django_assert_num_queries, monkeypatch, rf, settings
|
||||||
def test_authentication_verify_claims_default(django_assert_num_queries, monkeypatch):
|
|
||||||
"""The sub claim should be mandatory by default."""
|
|
||||||
klass = OIDCAuthenticationBackend()
|
|
||||||
|
|
||||||
def get_userinfo_mocked(*args):
|
|
||||||
return {
|
|
||||||
"test": "123",
|
|
||||||
}
|
|
||||||
|
|
||||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
|
||||||
|
|
||||||
with (
|
|
||||||
django_assert_num_queries(0),
|
|
||||||
pytest.raises(
|
|
||||||
KeyError,
|
|
||||||
match="sub",
|
|
||||||
),
|
|
||||||
):
|
|
||||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
|
||||||
|
|
||||||
assert models.User.objects.exists() is False
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"essential_claims, missing_claims",
|
|
||||||
[
|
|
||||||
(["email", "sub"], ["email"]),
|
|
||||||
(["Email", "sub"], ["Email"]), # Case sensitivity
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
|
|
||||||
@mock.patch.object(Logger, "error")
|
|
||||||
def test_authentication_verify_claims_essential_missing(
|
|
||||||
mock_logger,
|
|
||||||
essential_claims,
|
|
||||||
missing_claims,
|
|
||||||
django_assert_num_queries,
|
|
||||||
monkeypatch,
|
|
||||||
):
|
):
|
||||||
"""Ensure SuspiciousOperation is raised if essential claims are missing."""
|
"""
|
||||||
|
Test that the session contains oidc_refresh_token and oidc_access_token after authentication.
|
||||||
|
"""
|
||||||
|
settings.OIDC_OP_TOKEN_ENDPOINT = "http://oidc.endpoint.test/token"
|
||||||
|
settings.OIDC_OP_USER_ENDPOINT = "http://oidc.endpoint.test/userinfo"
|
||||||
|
settings.OIDC_OP_JWKS_ENDPOINT = "http://oidc.endpoint.test/jwks"
|
||||||
|
settings.OIDC_STORE_ACCESS_TOKEN = True
|
||||||
|
settings.OIDC_STORE_REFRESH_TOKEN = True
|
||||||
|
settings.OIDC_STORE_REFRESH_TOKEN_KEY = Fernet.generate_key()
|
||||||
|
|
||||||
klass = OIDCAuthenticationBackend()
|
klass = OIDCAuthenticationBackend()
|
||||||
|
request = rf.get("/some-url", {"state": "test-state", "code": "test-code"})
|
||||||
|
request.session = {}
|
||||||
|
|
||||||
def get_userinfo_mocked(*args):
|
def verify_token_mocked(*args, **kwargs):
|
||||||
return {
|
return {"sub": "123", "email": "test@example.com"}
|
||||||
"sub": "123",
|
|
||||||
"last_name": "Doe",
|
|
||||||
}
|
|
||||||
|
|
||||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
monkeypatch.setattr(OIDCAuthenticationBackend, "verify_token", verify_token_mocked)
|
||||||
|
|
||||||
with (
|
responses.add(
|
||||||
django_assert_num_queries(0),
|
responses.POST,
|
||||||
pytest.raises(
|
re.compile(settings.OIDC_OP_TOKEN_ENDPOINT),
|
||||||
SuspiciousOperation,
|
json={
|
||||||
match="Claims verification failed",
|
"access_token": "test-access-token",
|
||||||
),
|
"refresh_token": "test-refresh-token",
|
||||||
override_settings(USER_OIDC_ESSENTIAL_CLAIMS=essential_claims),
|
},
|
||||||
):
|
status=200,
|
||||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
)
|
||||||
|
|
||||||
assert models.User.objects.exists() is False
|
responses.add(
|
||||||
mock_logger.assert_called_once_with("Missing essential claims: %s", missing_claims)
|
responses.GET,
|
||||||
|
re.compile(settings.OIDC_OP_USER_ENDPOINT),
|
||||||
|
json={"sub": "123", "email": "test@example.com"},
|
||||||
@override_settings(
|
status=200,
|
||||||
OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo",
|
)
|
||||||
USER_OIDC_ESSENTIAL_CLAIMS=["email", "last_name"],
|
|
||||||
)
|
|
||||||
def test_authentication_verify_claims_success(django_assert_num_queries, monkeypatch):
|
|
||||||
"""Ensure user is authenticated when all essential claims are present."""
|
|
||||||
|
|
||||||
klass = OIDCAuthenticationBackend()
|
|
||||||
|
|
||||||
def get_userinfo_mocked(*args):
|
|
||||||
return {
|
|
||||||
"email": "john.doe@example.com",
|
|
||||||
"last_name": "Doe",
|
|
||||||
"sub": "123",
|
|
||||||
}
|
|
||||||
|
|
||||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
|
||||||
|
|
||||||
with django_assert_num_queries(6):
|
with django_assert_num_queries(6):
|
||||||
user = klass.get_or_create_user(
|
user = klass.authenticate(
|
||||||
access_token="test-token", id_token=None, payload=None
|
request,
|
||||||
|
code="test-code",
|
||||||
|
nonce="test-nonce",
|
||||||
|
code_verifier="test-code-verifier",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert models.User.objects.filter(id=user.id).exists()
|
assert user is not None
|
||||||
|
assert request.session["oidc_access_token"] == "test-access-token"
|
||||||
assert user.sub == "123"
|
assert get_oidc_refresh_token(request.session) == "test-refresh-token"
|
||||||
assert user.full_name == "Doe"
|
|
||||||
assert user.short_name is None
|
|
||||||
assert user.email == "john.doe@example.com"
|
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
"""Unit tests for the Authentication URLs."""
|
|
||||||
|
|
||||||
from core.authentication.urls import urlpatterns
|
|
||||||
|
|
||||||
|
|
||||||
def test_urls_override_default_mozilla_django_oidc():
|
|
||||||
"""Custom URL patterns should override default ones from Mozilla Django OIDC."""
|
|
||||||
|
|
||||||
url_names = [u.name for u in urlpatterns]
|
|
||||||
assert url_names.index("oidc_logout_custom") < url_names.index("oidc_logout")
|
|
||||||
@@ -1,231 +0,0 @@
|
|||||||
"""Unit tests for the Authentication Views."""
|
|
||||||
|
|
||||||
from unittest import mock
|
|
||||||
from urllib.parse import parse_qs, urlparse
|
|
||||||
|
|
||||||
from django.contrib.auth.models import AnonymousUser
|
|
||||||
from django.contrib.sessions.middleware import SessionMiddleware
|
|
||||||
from django.core.exceptions import SuspiciousOperation
|
|
||||||
from django.test import RequestFactory
|
|
||||||
from django.test.utils import override_settings
|
|
||||||
from django.urls import reverse
|
|
||||||
from django.utils import crypto
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from rest_framework.test import APIClient
|
|
||||||
|
|
||||||
from core import factories
|
|
||||||
from core.authentication.views import OIDCLogoutCallbackView, OIDCLogoutView
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
|
||||||
|
|
||||||
|
|
||||||
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
|
|
||||||
def test_view_logout_anonymous():
|
|
||||||
"""Anonymous users calling the logout url,
|
|
||||||
should be redirected to the specified LOGOUT_REDIRECT_URL."""
|
|
||||||
|
|
||||||
url = reverse("oidc_logout_custom")
|
|
||||||
response = APIClient().get(url)
|
|
||||||
|
|
||||||
assert response.status_code == 302
|
|
||||||
assert response.url == "/example-logout"
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.object(
|
|
||||||
OIDCLogoutView, "construct_oidc_logout_url", return_value="/example-logout"
|
|
||||||
)
|
|
||||||
def test_view_logout(mocked_oidc_logout_url):
|
|
||||||
"""Authenticated users should be redirected to OIDC provider for logout."""
|
|
||||||
|
|
||||||
user = factories.UserFactory()
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
url = reverse("oidc_logout_custom")
|
|
||||||
response = client.get(url)
|
|
||||||
|
|
||||||
mocked_oidc_logout_url.assert_called_once()
|
|
||||||
|
|
||||||
assert response.status_code == 302
|
|
||||||
assert response.url == "/example-logout"
|
|
||||||
|
|
||||||
|
|
||||||
@override_settings(LOGOUT_REDIRECT_URL="/default-redirect-logout")
|
|
||||||
@mock.patch.object(
|
|
||||||
OIDCLogoutView, "construct_oidc_logout_url", return_value="/default-redirect-logout"
|
|
||||||
)
|
|
||||||
def test_view_logout_no_oidc_provider(mocked_oidc_logout_url):
|
|
||||||
"""Authenticated users should be logged out when no OIDC provider is available."""
|
|
||||||
|
|
||||||
user = factories.UserFactory()
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
url = reverse("oidc_logout_custom")
|
|
||||||
|
|
||||||
with mock.patch("mozilla_django_oidc.views.auth.logout") as mock_logout:
|
|
||||||
response = client.get(url)
|
|
||||||
mocked_oidc_logout_url.assert_called_once()
|
|
||||||
mock_logout.assert_called_once()
|
|
||||||
|
|
||||||
assert response.status_code == 302
|
|
||||||
assert response.url == "/default-redirect-logout"
|
|
||||||
|
|
||||||
|
|
||||||
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
|
|
||||||
def test_view_logout_callback_anonymous():
|
|
||||||
"""Anonymous users calling the logout callback url,
|
|
||||||
should be redirected to the specified LOGOUT_REDIRECT_URL."""
|
|
||||||
|
|
||||||
url = reverse("oidc_logout_callback")
|
|
||||||
response = APIClient().get(url)
|
|
||||||
|
|
||||||
assert response.status_code == 302
|
|
||||||
assert response.url == "/example-logout"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"initial_oidc_states",
|
|
||||||
[{}, {"other_state": "foo"}],
|
|
||||||
)
|
|
||||||
def test_view_logout_persist_state(initial_oidc_states):
|
|
||||||
"""State value should be persisted in session's data."""
|
|
||||||
|
|
||||||
user = factories.UserFactory()
|
|
||||||
|
|
||||||
request = RequestFactory().request()
|
|
||||||
request.user = user
|
|
||||||
|
|
||||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
|
||||||
middleware.process_request(request)
|
|
||||||
|
|
||||||
if initial_oidc_states:
|
|
||||||
request.session["oidc_states"] = initial_oidc_states
|
|
||||||
request.session.save()
|
|
||||||
|
|
||||||
mocked_state = "mock_state"
|
|
||||||
|
|
||||||
OIDCLogoutView().persist_state(request, mocked_state)
|
|
||||||
|
|
||||||
assert "oidc_states" in request.session
|
|
||||||
assert request.session["oidc_states"] == {
|
|
||||||
"mock_state": {},
|
|
||||||
**initial_oidc_states,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@override_settings(OIDC_OP_LOGOUT_ENDPOINT="/example-logout")
|
|
||||||
@mock.patch.object(OIDCLogoutView, "persist_state")
|
|
||||||
@mock.patch.object(crypto, "get_random_string", return_value="mocked_state")
|
|
||||||
def test_view_logout_construct_oidc_logout_url(
|
|
||||||
mocked_get_random_string, mocked_persist_state
|
|
||||||
):
|
|
||||||
"""Should construct the logout URL to initiate the logout flow with the OIDC provider."""
|
|
||||||
|
|
||||||
user = factories.UserFactory()
|
|
||||||
|
|
||||||
request = RequestFactory().request()
|
|
||||||
request.user = user
|
|
||||||
|
|
||||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
|
||||||
middleware.process_request(request)
|
|
||||||
|
|
||||||
request.session["oidc_id_token"] = "mocked_oidc_id_token"
|
|
||||||
request.session.save()
|
|
||||||
|
|
||||||
redirect_url = OIDCLogoutView().construct_oidc_logout_url(request)
|
|
||||||
|
|
||||||
mocked_persist_state.assert_called_once()
|
|
||||||
mocked_get_random_string.assert_called_once()
|
|
||||||
|
|
||||||
params = parse_qs(urlparse(redirect_url).query)
|
|
||||||
|
|
||||||
assert params["id_token_hint"][0] == "mocked_oidc_id_token"
|
|
||||||
assert params["state"][0] == "mocked_state"
|
|
||||||
|
|
||||||
url = reverse("oidc_logout_callback")
|
|
||||||
assert url in params["post_logout_redirect_uri"][0]
|
|
||||||
|
|
||||||
|
|
||||||
@override_settings(LOGOUT_REDIRECT_URL="/")
|
|
||||||
def test_view_logout_construct_oidc_logout_url_none_id_token():
|
|
||||||
"""If no ID token is available in the session,
|
|
||||||
the user should be redirected to the final URL."""
|
|
||||||
|
|
||||||
user = factories.UserFactory()
|
|
||||||
|
|
||||||
request = RequestFactory().request()
|
|
||||||
request.user = user
|
|
||||||
|
|
||||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
|
||||||
middleware.process_request(request)
|
|
||||||
|
|
||||||
redirect_url = OIDCLogoutView().construct_oidc_logout_url(request)
|
|
||||||
|
|
||||||
assert redirect_url == "/"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"initial_state",
|
|
||||||
[None, {"other_state": "foo"}],
|
|
||||||
)
|
|
||||||
def test_view_logout_callback_wrong_state(initial_state):
|
|
||||||
"""Should raise an error if OIDC state doesn't match session data."""
|
|
||||||
|
|
||||||
user = factories.UserFactory()
|
|
||||||
|
|
||||||
request = RequestFactory().request()
|
|
||||||
request.user = user
|
|
||||||
|
|
||||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
|
||||||
middleware.process_request(request)
|
|
||||||
|
|
||||||
if initial_state:
|
|
||||||
request.session["oidc_states"] = initial_state
|
|
||||||
request.session.save()
|
|
||||||
|
|
||||||
callback_view = OIDCLogoutCallbackView.as_view()
|
|
||||||
|
|
||||||
with pytest.raises(SuspiciousOperation) as excinfo:
|
|
||||||
callback_view(request)
|
|
||||||
|
|
||||||
assert (
|
|
||||||
str(excinfo.value) == "OIDC callback state not found in session `oidc_states`!"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
|
|
||||||
def test_view_logout_callback():
|
|
||||||
"""If state matches, callback should clear OIDC state and redirects."""
|
|
||||||
|
|
||||||
user = factories.UserFactory()
|
|
||||||
|
|
||||||
request = RequestFactory().get("/logout-callback/", data={"state": "mocked_state"})
|
|
||||||
request.user = user
|
|
||||||
|
|
||||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
|
||||||
middleware.process_request(request)
|
|
||||||
|
|
||||||
mocked_state = "mocked_state"
|
|
||||||
|
|
||||||
request.session["oidc_states"] = {mocked_state: {}}
|
|
||||||
request.session.save()
|
|
||||||
|
|
||||||
callback_view = OIDCLogoutCallbackView.as_view()
|
|
||||||
|
|
||||||
with mock.patch("mozilla_django_oidc.views.auth.logout") as mock_logout:
|
|
||||||
|
|
||||||
def clear_user(request):
|
|
||||||
# Assert state is cleared prior to logout
|
|
||||||
assert request.session["oidc_states"] == {}
|
|
||||||
request.user = AnonymousUser()
|
|
||||||
|
|
||||||
mock_logout.side_effect = clear_user
|
|
||||||
response = callback_view(request)
|
|
||||||
mock_logout.assert_called_once()
|
|
||||||
|
|
||||||
assert response.status_code == 302
|
|
||||||
assert response.url == "/example-logout"
|
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
USER = "user"
|
USER = "user"
|
||||||
@@ -9,6 +11,12 @@ TEAM = "team"
|
|||||||
VIA = [USER, TEAM]
|
VIA = [USER, TEAM]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clear_cache():
|
||||||
|
"""Fixture to clear the cache before each test."""
|
||||||
|
cache.clear()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_user_teams():
|
def mock_user_teams():
|
||||||
"""Mock for the "teams" property on the User model."""
|
"""Mock for the "teams" property on the User model."""
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
"""
|
"""
|
||||||
Test document accesses API endpoints for users in impress's core app.
|
Test document accesses API endpoints for users in impress's core app.
|
||||||
"""
|
"""
|
||||||
|
# pylint: disable=too-many-lines
|
||||||
|
|
||||||
import random
|
import random
|
||||||
|
from unittest import mock
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from core import factories, models
|
from core import choices, factories, models
|
||||||
from core.api import serializers
|
from core.api import serializers
|
||||||
from core.tests.conftest import TEAM, USER, VIA
|
from core.tests.conftest import TEAM, USER, VIA
|
||||||
from core.tests.test_services_collaboration_services import ( # pylint: disable=unused-import
|
from core.tests.test_services_collaboration_services import ( # pylint: disable=unused-import
|
||||||
@@ -51,86 +53,476 @@ def test_api_document_accesses_list_authenticated_unrelated():
|
|||||||
f"/api/v1.0/documents/{document.id!s}/accesses/",
|
f"/api/v1.0/documents/{document.id!s}/accesses/",
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == []
|
||||||
"count": 0,
|
|
||||||
"next": None,
|
|
||||||
"previous": None,
|
|
||||||
"results": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("via", VIA)
|
def test_api_document_accesses_list_unexisting_document():
|
||||||
def test_api_document_accesses_list_authenticated_related(via, mock_user_teams):
|
|
||||||
"""
|
"""
|
||||||
Authenticated users should be able to list document accesses for a document
|
Listing document accesses for an unexisting document should return an empty list.
|
||||||
to which they are directly related, whatever their role in the document.
|
|
||||||
"""
|
"""
|
||||||
user = factories.UserFactory()
|
user = factories.UserFactory()
|
||||||
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
document = factories.DocumentFactory()
|
response = client.get(f"/api/v1.0/documents/{uuid4()!s}/accesses/")
|
||||||
user_access = None
|
assert response.status_code == 404
|
||||||
|
assert response.json() == {"detail": "Not found."}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("via", VIA)
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"role",
|
||||||
|
[role for role in choices.RoleChoices if role not in choices.PRIVILEGED_ROLES],
|
||||||
|
)
|
||||||
|
def test_api_document_accesses_list_authenticated_related_non_privileged(
|
||||||
|
via, role, mock_user_teams, django_assert_num_queries
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Authenticated users with no privileged role should only be able to list document
|
||||||
|
accesses associated with privileged roles for a document, including from ancestors.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
# Create documents structured as a tree
|
||||||
|
unreadable_ancestor = factories.DocumentFactory(link_reach="restricted")
|
||||||
|
# make all documents below the grand parent readable without a specific access for the user
|
||||||
|
grand_parent = factories.DocumentFactory(
|
||||||
|
parent=unreadable_ancestor, link_reach="authenticated"
|
||||||
|
)
|
||||||
|
parent = factories.DocumentFactory(parent=grand_parent)
|
||||||
|
document = factories.DocumentFactory(parent=parent)
|
||||||
|
child = factories.DocumentFactory(parent=document)
|
||||||
|
|
||||||
|
# Create accesses related to each document
|
||||||
|
accesses = (
|
||||||
|
factories.UserDocumentAccessFactory(document=unreadable_ancestor),
|
||||||
|
factories.UserDocumentAccessFactory(document=grand_parent),
|
||||||
|
factories.UserDocumentAccessFactory(document=parent),
|
||||||
|
factories.UserDocumentAccessFactory(document=document),
|
||||||
|
factories.TeamDocumentAccessFactory(document=document),
|
||||||
|
)
|
||||||
|
factories.UserDocumentAccessFactory(document=child)
|
||||||
|
|
||||||
|
if via == USER:
|
||||||
|
models.DocumentAccess.objects.create(
|
||||||
|
document=document,
|
||||||
|
user=user,
|
||||||
|
role=role,
|
||||||
|
)
|
||||||
|
elif via == TEAM:
|
||||||
|
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||||
|
models.DocumentAccess.objects.create(
|
||||||
|
document=document,
|
||||||
|
team="lasuite",
|
||||||
|
role=role,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Accesses for other documents to which the user is related should not be listed either
|
||||||
|
other_access = factories.UserDocumentAccessFactory(user=user)
|
||||||
|
factories.UserDocumentAccessFactory(document=other_access.document)
|
||||||
|
|
||||||
|
with django_assert_num_queries(3):
|
||||||
|
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
content = response.json()
|
||||||
|
|
||||||
|
# Make sure only privileged roles are returned
|
||||||
|
privileged_accesses = [
|
||||||
|
acc for acc in accesses if acc.role in choices.PRIVILEGED_ROLES
|
||||||
|
]
|
||||||
|
assert len(content) == len(privileged_accesses)
|
||||||
|
|
||||||
|
assert sorted(content, key=lambda x: x["id"]) == sorted(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": str(access.id),
|
||||||
|
"document": {
|
||||||
|
"id": str(access.document_id),
|
||||||
|
"path": access.document.path,
|
||||||
|
"depth": access.document.depth,
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"full_name": access.user.full_name,
|
||||||
|
"short_name": access.user.short_name,
|
||||||
|
}
|
||||||
|
if access.user
|
||||||
|
else None,
|
||||||
|
"team": access.team,
|
||||||
|
"role": access.role,
|
||||||
|
"max_ancestors_role": None,
|
||||||
|
"max_role": access.role,
|
||||||
|
"abilities": {
|
||||||
|
"destroy": False,
|
||||||
|
"partial_update": False,
|
||||||
|
"retrieve": False,
|
||||||
|
"set_role_to": [],
|
||||||
|
"update": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for access in privileged_accesses
|
||||||
|
],
|
||||||
|
key=lambda x: x["id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("via", VIA)
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"role", [role for role in choices.RoleChoices if role in choices.PRIVILEGED_ROLES]
|
||||||
|
)
|
||||||
|
def test_api_document_accesses_list_authenticated_related_privileged(
|
||||||
|
via, role, mock_user_teams, django_assert_num_queries
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Authenticated users with a privileged role should be able to list all
|
||||||
|
document accesses whatever the role, including from ancestors.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
# Create documents structured as a tree
|
||||||
|
unreadable_ancestor = factories.DocumentFactory(link_reach="restricted")
|
||||||
|
# make all documents below the grand parent readable without a specific access for the user
|
||||||
|
grand_parent = factories.DocumentFactory(
|
||||||
|
parent=unreadable_ancestor, link_reach="authenticated"
|
||||||
|
)
|
||||||
|
parent = factories.DocumentFactory(parent=grand_parent)
|
||||||
|
document = factories.DocumentFactory(parent=parent)
|
||||||
|
child = factories.DocumentFactory(parent=document)
|
||||||
|
|
||||||
if via == USER:
|
if via == USER:
|
||||||
user_access = models.DocumentAccess.objects.create(
|
user_access = models.DocumentAccess.objects.create(
|
||||||
document=document,
|
document=document,
|
||||||
user=user,
|
user=user,
|
||||||
role=random.choice(models.RoleChoices.values),
|
role=role,
|
||||||
)
|
)
|
||||||
elif via == TEAM:
|
elif via == TEAM:
|
||||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||||
user_access = models.DocumentAccess.objects.create(
|
user_access = models.DocumentAccess.objects.create(
|
||||||
document=document,
|
document=document,
|
||||||
team="lasuite",
|
team="lasuite",
|
||||||
role=random.choice(models.RoleChoices.values),
|
role=role,
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
raise RuntimeError()
|
||||||
|
|
||||||
access1 = factories.TeamDocumentAccessFactory(document=document)
|
# Create accesses related to each document
|
||||||
access2 = factories.UserDocumentAccessFactory(document=document)
|
ancestors_accesses = [
|
||||||
|
# Access on unreadable ancestor should still be listed
|
||||||
|
# as the related user gains access to our document
|
||||||
|
factories.UserDocumentAccessFactory(document=unreadable_ancestor),
|
||||||
|
factories.UserDocumentAccessFactory(document=grand_parent),
|
||||||
|
factories.UserDocumentAccessFactory(document=parent),
|
||||||
|
]
|
||||||
|
document_accesses = [
|
||||||
|
factories.UserDocumentAccessFactory(document=document),
|
||||||
|
factories.TeamDocumentAccessFactory(document=document),
|
||||||
|
factories.UserDocumentAccessFactory(document=document),
|
||||||
|
user_access,
|
||||||
|
]
|
||||||
|
factories.UserDocumentAccessFactory(document=child)
|
||||||
|
|
||||||
# Accesses for other documents to which the user is related should not be listed either
|
# Accesses for other documents to which the user is related should not be listed either
|
||||||
other_access = factories.UserDocumentAccessFactory(user=user)
|
other_access = factories.UserDocumentAccessFactory(user=user)
|
||||||
factories.UserDocumentAccessFactory(document=other_access.document)
|
factories.UserDocumentAccessFactory(document=other_access.document)
|
||||||
|
|
||||||
response = client.get(
|
with django_assert_num_queries(3):
|
||||||
f"/api/v1.0/documents/{document.id!s}/accesses/",
|
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
|
||||||
)
|
|
||||||
|
|
||||||
access2_user = serializers.UserSerializer(instance=access2.user).data
|
|
||||||
base_user = serializers.UserSerializer(instance=user).data
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
content = response.json()
|
content = response.json()
|
||||||
assert len(content["results"]) == 3
|
assert len(content) == 7
|
||||||
assert sorted(content["results"], key=lambda x: x["id"]) == sorted(
|
assert sorted(content, key=lambda x: x["id"]) == sorted(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"id": str(user_access.id),
|
"id": str(access.id),
|
||||||
"user": base_user if via == "user" else None,
|
"document": {
|
||||||
"team": "lasuite" if via == "team" else "",
|
"id": str(access.document_id),
|
||||||
"role": user_access.role,
|
"path": access.document.path,
|
||||||
"abilities": user_access.get_abilities(user),
|
"depth": access.document.depth,
|
||||||
},
|
},
|
||||||
{
|
"user": {
|
||||||
"id": str(access1.id),
|
"id": str(access.user.id),
|
||||||
"user": None,
|
"email": access.user.email,
|
||||||
"team": access1.team,
|
"language": access.user.language,
|
||||||
"role": access1.role,
|
"full_name": access.user.full_name,
|
||||||
"abilities": access1.get_abilities(user),
|
"short_name": access.user.short_name,
|
||||||
},
|
}
|
||||||
{
|
if access.user
|
||||||
"id": str(access2.id),
|
else None,
|
||||||
"user": access2_user,
|
"max_ancestors_role": None,
|
||||||
"team": "",
|
"max_role": access.role,
|
||||||
"role": access2.role,
|
"team": access.team,
|
||||||
"abilities": access2.get_abilities(user),
|
"role": access.role,
|
||||||
},
|
"abilities": access.get_abilities(user),
|
||||||
|
}
|
||||||
|
for access in ancestors_accesses + document_accesses
|
||||||
],
|
],
|
||||||
key=lambda x: x["id"],
|
key=lambda x: x["id"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_document_accesses_retrieve_set_role_to_child():
|
||||||
|
"""Check set_role_to for an access with no access on the ancestor."""
|
||||||
|
user, other_user = factories.UserFactory.create_batch(2)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
parent = factories.DocumentFactory()
|
||||||
|
parent_access = factories.UserDocumentAccessFactory(
|
||||||
|
document=parent, user=user, role="owner"
|
||||||
|
)
|
||||||
|
|
||||||
|
document = factories.DocumentFactory(parent=parent)
|
||||||
|
document_access_other_user = factories.UserDocumentAccessFactory(
|
||||||
|
document=document, user=other_user, role="editor"
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
content = response.json()
|
||||||
|
assert len(content) == 2
|
||||||
|
|
||||||
|
result_dict = {
|
||||||
|
result["id"]: result["abilities"]["set_role_to"] for result in content
|
||||||
|
}
|
||||||
|
assert result_dict[str(document_access_other_user.id)] == [
|
||||||
|
"reader",
|
||||||
|
"commenter",
|
||||||
|
"editor",
|
||||||
|
"administrator",
|
||||||
|
"owner",
|
||||||
|
]
|
||||||
|
assert result_dict[str(parent_access.id)] == []
|
||||||
|
|
||||||
|
# Add an access for the other user on the parent
|
||||||
|
parent_access_other_user = factories.UserDocumentAccessFactory(
|
||||||
|
document=parent, user=other_user, role="commenter"
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
content = response.json()
|
||||||
|
assert len(content) == 3
|
||||||
|
|
||||||
|
result_dict = {
|
||||||
|
result["id"]: result["abilities"]["set_role_to"] for result in content
|
||||||
|
}
|
||||||
|
assert result_dict[str(document_access_other_user.id)] == [
|
||||||
|
"commenter",
|
||||||
|
"editor",
|
||||||
|
"administrator",
|
||||||
|
"owner",
|
||||||
|
]
|
||||||
|
assert result_dict[str(parent_access.id)] == []
|
||||||
|
assert result_dict[str(parent_access_other_user.id)] == [
|
||||||
|
"reader",
|
||||||
|
"commenter",
|
||||||
|
"editor",
|
||||||
|
"administrator",
|
||||||
|
"owner",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"roles,results",
|
||||||
|
[
|
||||||
|
[
|
||||||
|
["administrator", "reader", "reader", "reader"],
|
||||||
|
[
|
||||||
|
["reader", "commenter", "editor", "administrator"],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
["reader", "commenter", "editor", "administrator"],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
["owner", "reader", "reader", "reader"],
|
||||||
|
[
|
||||||
|
["reader", "commenter", "editor", "administrator", "owner"],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
["reader", "commenter", "editor", "administrator", "owner"],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
["owner", "reader", "reader", "owner"],
|
||||||
|
[
|
||||||
|
["reader", "commenter", "editor", "administrator", "owner"],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
["reader", "commenter", "editor", "administrator", "owner"],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_api_document_accesses_list_authenticated_related_same_user(roles, results):
|
||||||
|
"""
|
||||||
|
The maximum role across ancestor documents and set_role_to optionsfor
|
||||||
|
a given user should be filled as expected.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
# Create documents structured as a tree
|
||||||
|
grand_parent = factories.DocumentFactory(link_reach="authenticated")
|
||||||
|
parent = factories.DocumentFactory(parent=grand_parent)
|
||||||
|
document = factories.DocumentFactory(parent=parent)
|
||||||
|
|
||||||
|
# Create accesses for another user
|
||||||
|
other_user = factories.UserFactory()
|
||||||
|
accesses = [
|
||||||
|
factories.UserDocumentAccessFactory(
|
||||||
|
document=document, user=user, role=roles[0]
|
||||||
|
),
|
||||||
|
factories.UserDocumentAccessFactory(
|
||||||
|
document=grand_parent, user=other_user, role=roles[1]
|
||||||
|
),
|
||||||
|
factories.UserDocumentAccessFactory(
|
||||||
|
document=parent, user=other_user, role=roles[2]
|
||||||
|
),
|
||||||
|
factories.UserDocumentAccessFactory(
|
||||||
|
document=document, user=other_user, role=roles[3]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
content = response.json()
|
||||||
|
assert len(content) == 4
|
||||||
|
|
||||||
|
for result in content:
|
||||||
|
assert (
|
||||||
|
result["max_ancestors_role"] is None
|
||||||
|
if result["user"]["id"] == str(user.id)
|
||||||
|
else choices.RoleChoices.max(roles[1], roles[2])
|
||||||
|
)
|
||||||
|
|
||||||
|
result_dict = {
|
||||||
|
result["id"]: result["abilities"]["set_role_to"] for result in content
|
||||||
|
}
|
||||||
|
assert [result_dict[str(access.id)] for access in accesses] == results
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"roles,results",
|
||||||
|
[
|
||||||
|
[
|
||||||
|
["administrator", "reader", "reader", "reader"],
|
||||||
|
[
|
||||||
|
["reader", "commenter", "editor", "administrator"],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
["reader", "commenter", "editor", "administrator"],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
["owner", "reader", "reader", "reader"],
|
||||||
|
[
|
||||||
|
["reader", "commenter", "editor", "administrator", "owner"],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
["reader", "commenter", "editor", "administrator", "owner"],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
["owner", "reader", "reader", "owner"],
|
||||||
|
[
|
||||||
|
["reader", "commenter", "editor", "administrator", "owner"],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
["reader", "commenter", "editor", "administrator", "owner"],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
["reader", "reader", "reader", "owner"],
|
||||||
|
[
|
||||||
|
["reader", "commenter", "editor", "administrator", "owner"],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
["reader", "commenter", "editor", "administrator", "owner"],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
["reader", "administrator", "reader", "editor"],
|
||||||
|
[
|
||||||
|
["reader", "commenter", "editor", "administrator"],
|
||||||
|
["reader", "commenter", "editor", "administrator"],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
["editor", "editor", "administrator", "editor"],
|
||||||
|
[
|
||||||
|
["reader", "commenter", "editor", "administrator"],
|
||||||
|
[],
|
||||||
|
["editor", "administrator"],
|
||||||
|
[],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_api_document_accesses_list_authenticated_related_same_team(
|
||||||
|
roles, results, mock_user_teams
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
The maximum role across ancestor documents and set_role_to optionsfor
|
||||||
|
a given team should be filled as expected.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
# Create documents structured as a tree
|
||||||
|
grand_parent = factories.DocumentFactory(link_reach="authenticated")
|
||||||
|
parent = factories.DocumentFactory(parent=grand_parent)
|
||||||
|
document = factories.DocumentFactory(parent=parent)
|
||||||
|
|
||||||
|
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||||
|
accesses = [
|
||||||
|
factories.UserDocumentAccessFactory(
|
||||||
|
document=document, user=user, role=roles[0]
|
||||||
|
),
|
||||||
|
# Create accesses for a team
|
||||||
|
factories.TeamDocumentAccessFactory(
|
||||||
|
document=grand_parent, team="lasuite", role=roles[1]
|
||||||
|
),
|
||||||
|
factories.TeamDocumentAccessFactory(
|
||||||
|
document=parent, team="lasuite", role=roles[2]
|
||||||
|
),
|
||||||
|
factories.TeamDocumentAccessFactory(
|
||||||
|
document=document, team="lasuite", role=roles[3]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
content = response.json()
|
||||||
|
assert len(content) == 4
|
||||||
|
|
||||||
|
for result in content:
|
||||||
|
assert (
|
||||||
|
result["max_ancestors_role"] is None
|
||||||
|
if result["user"] and result["user"]["id"] == str(user.id)
|
||||||
|
else choices.RoleChoices.max(roles[1], roles[2])
|
||||||
|
)
|
||||||
|
|
||||||
|
result_dict = {
|
||||||
|
result["id"]: result["abilities"]["set_role_to"] for result in content
|
||||||
|
}
|
||||||
|
assert [result_dict[str(access.id)] for access in accesses] == results
|
||||||
|
|
||||||
|
|
||||||
def test_api_document_accesses_retrieve_anonymous():
|
def test_api_document_accesses_retrieve_anonymous():
|
||||||
"""
|
"""
|
||||||
Anonymous users should not be allowed to retrieve a document access.
|
Anonymous users should not be allowed to retrieve a document access.
|
||||||
@@ -184,7 +576,12 @@ def test_api_document_accesses_retrieve_authenticated_unrelated():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("via", VIA)
|
@pytest.mark.parametrize("via", VIA)
|
||||||
def test_api_document_accesses_retrieve_authenticated_related(via, mock_user_teams):
|
@pytest.mark.parametrize("role", models.RoleChoices)
|
||||||
|
def test_api_document_accesses_retrieve_authenticated_related(
|
||||||
|
via,
|
||||||
|
role,
|
||||||
|
mock_user_teams,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
A user who is related to a document should be allowed to retrieve the
|
A user who is related to a document should be allowed to retrieve the
|
||||||
associated document user accesses.
|
associated document user accesses.
|
||||||
@@ -196,10 +593,12 @@ def test_api_document_accesses_retrieve_authenticated_related(via, mock_user_tea
|
|||||||
|
|
||||||
document = factories.DocumentFactory()
|
document = factories.DocumentFactory()
|
||||||
if via == USER:
|
if via == USER:
|
||||||
factories.UserDocumentAccessFactory(document=document, user=user)
|
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
|
||||||
elif via == TEAM:
|
elif via == TEAM:
|
||||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||||
factories.TeamDocumentAccessFactory(document=document, team="lasuite")
|
factories.TeamDocumentAccessFactory(
|
||||||
|
document=document, team="lasuite", role=role
|
||||||
|
)
|
||||||
|
|
||||||
access = factories.UserDocumentAccessFactory(document=document)
|
access = factories.UserDocumentAccessFactory(document=document)
|
||||||
|
|
||||||
@@ -207,16 +606,26 @@ def test_api_document_accesses_retrieve_authenticated_related(via, mock_user_tea
|
|||||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||||
)
|
)
|
||||||
|
|
||||||
access_user = serializers.UserSerializer(instance=access.user).data
|
if not role in choices.PRIVILEGED_ROLES:
|
||||||
|
assert response.status_code == 403
|
||||||
|
else:
|
||||||
|
access_user = serializers.UserSerializer(instance=access.user).data
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": str(access.id),
|
"id": str(access.id),
|
||||||
"user": access_user,
|
"document": {
|
||||||
"team": "",
|
"id": str(access.document_id),
|
||||||
"role": access.role,
|
"path": access.document.path,
|
||||||
"abilities": access.get_abilities(user),
|
"depth": access.document.depth,
|
||||||
}
|
},
|
||||||
|
"user": access_user,
|
||||||
|
"team": "",
|
||||||
|
"role": access.role,
|
||||||
|
"max_ancestors_role": None,
|
||||||
|
"max_role": access.role,
|
||||||
|
"abilities": access.get_abilities(user),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_api_document_accesses_update_anonymous():
|
def test_api_document_accesses_update_anonymous():
|
||||||
@@ -319,7 +728,9 @@ def test_api_document_accesses_update_authenticated_reader_or_editor(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("via", VIA)
|
@pytest.mark.parametrize("via", VIA)
|
||||||
|
@pytest.mark.parametrize("create_for", VIA)
|
||||||
def test_api_document_accesses_update_administrator_except_owner(
|
def test_api_document_accesses_update_administrator_except_owner(
|
||||||
|
create_for,
|
||||||
via,
|
via,
|
||||||
mock_user_teams,
|
mock_user_teams,
|
||||||
mock_reset_connections, # pylint: disable=redefined-outer-name
|
mock_reset_connections, # pylint: disable=redefined-outer-name
|
||||||
@@ -352,32 +763,31 @@ def test_api_document_accesses_update_administrator_except_owner(
|
|||||||
|
|
||||||
new_values = {
|
new_values = {
|
||||||
"id": uuid4(),
|
"id": uuid4(),
|
||||||
"user_id": factories.UserFactory().id,
|
|
||||||
"role": random.choice(["administrator", "editor", "reader"]),
|
"role": random.choice(["administrator", "editor", "reader"]),
|
||||||
}
|
}
|
||||||
|
if create_for == USER:
|
||||||
|
new_values["user_id"] = factories.UserFactory().id
|
||||||
|
elif create_for == TEAM:
|
||||||
|
new_values["team"] = "new-team"
|
||||||
|
|
||||||
for field, value in new_values.items():
|
for field, value in new_values.items():
|
||||||
new_data = {**old_values, field: value}
|
new_data = {**old_values, field: value}
|
||||||
if new_data["role"] == old_values["role"]:
|
with mock_reset_connections(document.id, str(access.user_id)):
|
||||||
response = client.put(
|
response = client.put(
|
||||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||||
data=new_data,
|
data=new_data,
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
assert response.status_code == 403
|
assert response.status_code == 200
|
||||||
else:
|
|
||||||
with mock_reset_connections(document.id, str(access.user_id)):
|
|
||||||
response = client.put(
|
|
||||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
|
||||||
data=new_data,
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
access.refresh_from_db()
|
access.refresh_from_db()
|
||||||
updated_values = serializers.DocumentAccessSerializer(instance=access).data
|
updated_values = serializers.DocumentAccessSerializer(instance=access).data
|
||||||
if field == "role":
|
if field in ["role", "max_role"]:
|
||||||
assert updated_values == {**old_values, "role": new_values["role"]}
|
assert updated_values == {
|
||||||
|
**old_values,
|
||||||
|
"role": new_values["role"],
|
||||||
|
"max_role": new_values["role"],
|
||||||
|
}
|
||||||
else:
|
else:
|
||||||
assert updated_values == old_values
|
assert updated_values == old_values
|
||||||
|
|
||||||
@@ -472,7 +882,7 @@ def test_api_document_accesses_update_administrator_to_owner(
|
|||||||
for field, value in new_values.items():
|
for field, value in new_values.items():
|
||||||
new_data = {**old_values, field: value}
|
new_data = {**old_values, field: value}
|
||||||
# We are not allowed or not really updating the role
|
# We are not allowed or not really updating the role
|
||||||
if field == "role" or new_data["role"] == old_values["role"]:
|
if field == "role":
|
||||||
response = client.put(
|
response = client.put(
|
||||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||||
data=new_data,
|
data=new_data,
|
||||||
@@ -495,7 +905,9 @@ def test_api_document_accesses_update_administrator_to_owner(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("via", VIA)
|
@pytest.mark.parametrize("via", VIA)
|
||||||
|
@pytest.mark.parametrize("create_for", VIA)
|
||||||
def test_api_document_accesses_update_owner(
|
def test_api_document_accesses_update_owner(
|
||||||
|
create_for,
|
||||||
via,
|
via,
|
||||||
mock_user_teams,
|
mock_user_teams,
|
||||||
mock_reset_connections, # pylint: disable=redefined-outer-name
|
mock_reset_connections, # pylint: disable=redefined-outer-name
|
||||||
@@ -526,42 +938,39 @@ def test_api_document_accesses_update_owner(
|
|||||||
|
|
||||||
new_values = {
|
new_values = {
|
||||||
"id": uuid4(),
|
"id": uuid4(),
|
||||||
"user_id": factories.UserFactory().id,
|
|
||||||
"role": random.choice(models.RoleChoices.values),
|
"role": random.choice(models.RoleChoices.values),
|
||||||
}
|
}
|
||||||
|
if create_for == USER:
|
||||||
|
new_values["user_id"] = factories.UserFactory().id
|
||||||
|
elif create_for == TEAM:
|
||||||
|
new_values["team"] = "new-team"
|
||||||
|
|
||||||
for field, value in new_values.items():
|
for field, value in new_values.items():
|
||||||
new_data = {**old_values, field: value}
|
new_data = {**old_values, field: value}
|
||||||
if (
|
with mock_reset_connections(document.id, str(access.user_id)):
|
||||||
new_data["role"] == old_values["role"]
|
|
||||||
): # we are not really updating the role
|
|
||||||
response = client.put(
|
response = client.put(
|
||||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||||
data=new_data,
|
data=new_data,
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
assert response.status_code == 403
|
|
||||||
else:
|
|
||||||
with mock_reset_connections(document.id, str(access.user_id)):
|
|
||||||
response = client.put(
|
|
||||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
|
||||||
data=new_data,
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
access.refresh_from_db()
|
access.refresh_from_db()
|
||||||
updated_values = serializers.DocumentAccessSerializer(instance=access).data
|
updated_values = serializers.DocumentAccessSerializer(instance=access).data
|
||||||
|
|
||||||
if field == "role":
|
if field in ["role", "max_role"]:
|
||||||
assert updated_values == {**old_values, "role": new_values["role"]}
|
assert updated_values == {
|
||||||
|
**old_values,
|
||||||
|
"role": new_values["role"],
|
||||||
|
"max_role": new_values["role"],
|
||||||
|
}
|
||||||
else:
|
else:
|
||||||
assert updated_values == old_values
|
assert updated_values == old_values
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("via", VIA)
|
@pytest.mark.parametrize("via", VIA)
|
||||||
def test_api_document_accesses_update_owner_self(
|
def test_api_document_accesses_update_owner_self_root(
|
||||||
via,
|
via,
|
||||||
mock_user_teams,
|
mock_user_teams,
|
||||||
mock_reset_connections, # pylint: disable=redefined-outer-name
|
mock_reset_connections, # pylint: disable=redefined-outer-name
|
||||||
@@ -622,6 +1031,51 @@ def test_api_document_accesses_update_owner_self(
|
|||||||
assert access.role == new_role
|
assert access.role == new_role
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("via", VIA)
|
||||||
|
def test_api_document_accesses_update_owner_self_child(
|
||||||
|
via,
|
||||||
|
mock_user_teams,
|
||||||
|
mock_reset_connections, # pylint: disable=redefined-outer-name
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
A user who is owner of a document should be allowed to update
|
||||||
|
their own user access even if they are the only owner in the document,
|
||||||
|
provided the document is not a root.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
parent = factories.DocumentFactory()
|
||||||
|
document = factories.DocumentFactory(parent=parent)
|
||||||
|
access = None
|
||||||
|
if via == USER:
|
||||||
|
access = factories.UserDocumentAccessFactory(
|
||||||
|
document=document, user=user, role="owner"
|
||||||
|
)
|
||||||
|
elif via == TEAM:
|
||||||
|
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||||
|
access = factories.TeamDocumentAccessFactory(
|
||||||
|
document=document, team="lasuite", role="owner"
|
||||||
|
)
|
||||||
|
|
||||||
|
old_values = serializers.DocumentAccessSerializer(instance=access).data
|
||||||
|
new_role = random.choice(["administrator", "editor", "reader"])
|
||||||
|
|
||||||
|
user_id = str(access.user_id) if via == USER else None
|
||||||
|
with mock_reset_connections(document.id, user_id):
|
||||||
|
response = client.put(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||||
|
data={**old_values, "role": new_role},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
access.refresh_from_db()
|
||||||
|
assert access.role == new_role
|
||||||
|
|
||||||
|
|
||||||
# Delete
|
# Delete
|
||||||
|
|
||||||
|
|
||||||
@@ -802,17 +1256,16 @@ def test_api_document_accesses_delete_owners(
|
|||||||
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 204
|
assert response.status_code == 204
|
||||||
assert models.DocumentAccess.objects.count() == 1
|
assert models.DocumentAccess.objects.count() == 1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("via", VIA)
|
@pytest.mark.parametrize("via", VIA)
|
||||||
def test_api_document_accesses_delete_owners_last_owner(via, mock_user_teams):
|
def test_api_document_accesses_delete_owners_last_owner_root(via, mock_user_teams):
|
||||||
"""
|
"""
|
||||||
It should not be possible to delete the last owner access from a document
|
It should not be possible to delete the last owner access from a root document
|
||||||
"""
|
"""
|
||||||
user = factories.UserFactory(with_owned_document=True)
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
@@ -835,3 +1288,84 @@ def test_api_document_accesses_delete_owners_last_owner(via, mock_user_teams):
|
|||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
assert models.DocumentAccess.objects.count() == 2
|
assert models.DocumentAccess.objects.count() == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_document_accesses_delete_owners_last_owner_child_user(
|
||||||
|
mock_reset_connections, # pylint: disable=redefined-outer-name
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
It should be possible to delete the last owner access from a document that is not a root.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
parent = factories.DocumentFactory()
|
||||||
|
document = factories.DocumentFactory(parent=parent)
|
||||||
|
access = None
|
||||||
|
access = factories.UserDocumentAccessFactory(
|
||||||
|
document=document, user=user, role="owner"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert models.DocumentAccess.objects.count() == 2
|
||||||
|
with mock_reset_connections(document.id, str(access.user_id)):
|
||||||
|
response = client.delete(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 204
|
||||||
|
assert models.DocumentAccess.objects.count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip(
|
||||||
|
reason="Pending fix on https://github.com/suitenumerique/docs/issues/969"
|
||||||
|
)
|
||||||
|
def test_api_document_accesses_delete_owners_last_owner_child_team(
|
||||||
|
mock_user_teams,
|
||||||
|
mock_reset_connections, # pylint: disable=redefined-outer-name
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
It should be possible to delete the last owner access from a document that
|
||||||
|
is not a root.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory(with_owned_document=True)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
parent = factories.DocumentFactory()
|
||||||
|
document = factories.DocumentFactory(parent=parent)
|
||||||
|
access = None
|
||||||
|
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||||
|
access = factories.TeamDocumentAccessFactory(
|
||||||
|
document=document, team="lasuite", role="owner"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert models.DocumentAccess.objects.count() == 2
|
||||||
|
with mock_reset_connections(document.id, str(access.user_id)):
|
||||||
|
response = client.delete(
|
||||||
|
f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 204
|
||||||
|
assert models.DocumentAccess.objects.count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_document_accesses_throttling(settings):
|
||||||
|
"""Test api document accesses throttling."""
|
||||||
|
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["document_access"] = "2/minute"
|
||||||
|
user = factories.UserFactory()
|
||||||
|
document = factories.DocumentFactory()
|
||||||
|
factories.UserDocumentAccessFactory(
|
||||||
|
document=document, user=user, role="administrator"
|
||||||
|
)
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
for _i in range(2):
|
||||||
|
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
with mock.patch("core.api.throttling.capture_message") as mock_capture_message:
|
||||||
|
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
|
||||||
|
assert response.status_code == 429
|
||||||
|
mock_capture_message.assert_called_once_with(
|
||||||
|
"Rate limit exceeded for scope document_access", "warning"
|
||||||
|
)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user