mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2025-07-28 20:00:10 +02:00
Compare commits
2394 commits
v5.2.1-ext
...
ext-ce
Author | SHA1 | Date | |
---|---|---|---|
![]() |
3461c105f4 | ||
![]() |
bfc75552a5 | ||
![]() |
0d3890e2ae | ||
![]() |
a66cfc15ec | ||
![]() |
9f8136d13a | ||
![]() |
5e527b9a61 | ||
![]() |
88283f054d | ||
![]() |
e9efde94b7 | ||
![]() |
e8813f89cb | ||
![]() |
07a66fe94c | ||
![]() |
af636b8940 | ||
![]() |
08d22264c6 | ||
53f532fa29 | |||
![]() |
94c0234284 | ||
![]() |
176a1a4f96 | ||
![]() |
3ce4381768 | ||
![]() |
7de6dffb3c | ||
![]() |
dce4c64534 | ||
![]() |
8af4a2996a | ||
![]() |
73ff0a0eee | ||
![]() |
571735fd8f | ||
![]() |
2253ec577e | ||
![]() |
88486fa491 | ||
![]() |
26521730ef | ||
![]() |
7bb14e7e9d | ||
![]() |
9e0792f665 | ||
![]() |
8a20b2b5a1 | ||
![]() |
ae99d681bb | ||
![]() |
b035613237 | ||
![]() |
2a0d304b70 | ||
![]() |
da8bcee3c1 | ||
![]() |
f38be34f46 | ||
![]() |
d86d6519e6 | ||
![]() |
1e3e9a4096 | ||
![]() |
d0b38798d8 | ||
![]() |
a31f70f4db | ||
![]() |
bfecca5eb3 | ||
![]() |
b6f4eaf1df | ||
![]() |
747b021030 | ||
![]() |
db3f0d08dc | ||
![]() |
586afb3e70 | ||
![]() |
f90b086f32 | ||
![]() |
29ed51f81b | ||
![]() |
41d0404df4 | ||
![]() |
beff3fdb07 | ||
![]() |
f5859e373f | ||
![]() |
f1e9b0645c | ||
![]() |
47cefe1c45 | ||
![]() |
c15930080c | ||
![]() |
3b3fc01308 | ||
![]() |
5d3d056af2 | ||
![]() |
010192506b | ||
![]() |
9821e64994 | ||
![]() |
3eb637d999 | ||
![]() |
0546fb7233 | ||
![]() |
b1880ba64d | ||
![]() |
082121d3da | ||
![]() |
81f0807fc6 | ||
![]() |
bf43d4f709 | ||
![]() |
ae3f63d37f | ||
![]() |
30b0cabbbc | ||
![]() |
2f427ef0e0 | ||
![]() |
0778bab910 | ||
![]() |
d5b5710d01 | ||
![]() |
868d562d96 | ||
![]() |
5d79cf18c0 | ||
![]() |
7ecee2e0aa | ||
![]() |
f4dc8f7ebc | ||
![]() |
456f751a18 | ||
![]() |
48df8c9f38 | ||
![]() |
7540bc9cbe | ||
![]() |
5aacccc9d0 | ||
![]() |
3fe4cd31b9 | ||
![]() |
8d8142ba2b | ||
![]() |
cf668d897d | ||
![]() |
71a33925b6 | ||
![]() |
98af0e3d82 | ||
![]() |
5208ad39ec | ||
![]() |
2e82852ed0 | ||
![]() |
b0776da02c | ||
![]() |
ec2ab62f4d | ||
![]() |
be4a11484f | ||
![]() |
97eceb9c58 | ||
![]() |
63ca134fc5 | ||
![]() |
ee8e1915ab | ||
![]() |
f054a5658f | ||
![]() |
5df6047fd8 | ||
![]() |
29d9408a69 | ||
![]() |
9c16a85295 | ||
![]() |
5669a7d1c8 | ||
![]() |
93c6353b90 | ||
![]() |
9720413218 | ||
![]() |
8c39add865 | ||
![]() |
3e29af53a8 | ||
![]() |
1833bd3d00 | ||
![]() |
1daa49d9d2 | ||
![]() |
9e22ed9c3f | ||
![]() |
1375f695d3 | ||
![]() |
5b5e650754 | ||
![]() |
ce074ecf11 | ||
![]() |
42a408c6ae | ||
![]() |
488d0fdf9e | ||
![]() |
524402e817 | ||
![]() |
3c24c9bcc9 | ||
![]() |
1768bef22a | ||
![]() |
0f1d672a57 | ||
![]() |
2e2415c56e | ||
![]() |
caedd0f850 | ||
![]() |
1b70f09bee | ||
![]() |
fe8964cc0a | ||
![]() |
10f4722641 | ||
![]() |
4c03ebe4ee | ||
![]() |
c20c17b68e | ||
![]() |
ea2ba8cdbe | ||
![]() |
2ac46151f8 | ||
![]() |
122c618e53 | ||
![]() |
a9fa1aa598 | ||
![]() |
6abd4fe23e | ||
![]() |
e35f79bf32 | ||
![]() |
35f5588be4 | ||
![]() |
39b4581e1d | ||
![]() |
72aca352fc | ||
![]() |
5351488f0e | ||
![]() |
406312d495 | ||
![]() |
d5e00845c6 | ||
![]() |
bc49968908 | ||
![]() |
2f2862ecd7 | ||
![]() |
85ecef4d96 | ||
![]() |
8a07389ce6 | ||
![]() |
028d4b481f | ||
![]() |
fd8a4ac020 | ||
![]() |
e38b04a3ee | ||
![]() |
e7918247b4 | ||
![]() |
beb3d87a77 | ||
![]() |
d014db9893 | ||
![]() |
f0d2df3c43 | ||
![]() |
8fab1b54a3 | ||
![]() |
97f1425326 | ||
![]() |
f5dd356df3 | ||
![]() |
8efe921326 | ||
![]() |
ca426842c1 | ||
![]() |
da0967e902 | ||
![]() |
95b5c1f659 | ||
![]() |
6bdcd1f803 | ||
![]() |
14afc82acf | ||
![]() |
029395ebb9 | ||
![]() |
9e6ca01e7f | ||
![]() |
4ab4fbdf51 | ||
![]() |
6b6ff921ef | ||
![]() |
bfa0459e72 | ||
![]() |
e4a9b13e9a | ||
![]() |
102f3a5d5c | ||
![]() |
1083a05d69 | ||
![]() |
698d2aebb1 | ||
![]() |
8ea682cba3 | ||
![]() |
c62c2d6157 | ||
![]() |
1e9dcd9983 | ||
![]() |
da203d6e96 | ||
![]() |
298f56cbe5 | ||
![]() |
3c63c1ac3e | ||
![]() |
11cb140fe3 | ||
![]() |
d617fd0754 | ||
![]() |
3e9d60c25a | ||
![]() |
bd64f09b91 | ||
![]() |
b410de7c39 | ||
![]() |
b87b83e33c | ||
![]() |
b843603bb1 | ||
![]() |
b165fea0de | ||
![]() |
7219043c6b | ||
![]() |
bae2eb3861 | ||
![]() |
59b34d7c2b | ||
![]() |
905cc5d45f | ||
![]() |
9237d8227b | ||
![]() |
349fb62f60 | ||
![]() |
8adc7526d9 | ||
![]() |
97d2954cc0 | ||
![]() |
6969caf690 | ||
![]() |
e17eaf51d2 | ||
![]() |
faabd91e43 | ||
![]() |
496093203a | ||
![]() |
74063b14e0 | ||
![]() |
6ce114da77 | ||
![]() |
66b7fd8844 | ||
![]() |
ebf810e836 | ||
![]() |
9d1641a1ab | ||
![]() |
aa9dcb19a3 | ||
![]() |
9b43b82c95 | ||
![]() |
c5fad346f9 | ||
![]() |
37b63f9823 | ||
![]() |
cb945472c7 | ||
![]() |
9fc0373fab | ||
![]() |
1a1f283245 | ||
![]() |
f0827f0e67 | ||
![]() |
519b18e4a1 | ||
![]() |
1b03bb6e5d | ||
![]() |
8a5e2b0ea3 | ||
![]() |
6176f4d074 | ||
![]() |
132ccbc4cc | ||
![]() |
b2fb70c2b6 | ||
![]() |
5ed1225162 | ||
![]() |
83a00e7546 | ||
![]() |
16f3795c3e | ||
![]() |
b3c339464e | ||
![]() |
d4ab715a9b | ||
![]() |
5973c90e39 | ||
![]() |
2cacf8f645 | ||
![]() |
16135bde64 | ||
![]() |
23403a7ef2 | ||
![]() |
c10b95ae06 | ||
![]() |
b89951cf5d | ||
![]() |
56232b48a7 | ||
![]() |
bd3ef799f3 | ||
![]() |
913caca379 | ||
![]() |
c6b576b25a | ||
![]() |
d7a0cbefea | ||
![]() |
cf472f54d0 | ||
![]() |
d57d0ca738 | ||
![]() |
7d5bf2c0dd | ||
![]() |
0c462d45d1 | ||
![]() |
07b1701b72 | ||
![]() |
09a534f48b | ||
![]() |
bb5b9afd0e | ||
![]() |
3681be7a71 | ||
![]() |
2b8a14c4d2 | ||
![]() |
a7e3ce67ea | ||
![]() |
425344b40b | ||
![]() |
cbe96f21cb | ||
![]() |
4e40f24a9e | ||
![]() |
1b02a26d1f | ||
![]() |
8cd8d8239b | ||
![]() |
10b6f82677 | ||
![]() |
b7032e925f | ||
![]() |
9de32e1570 | ||
![]() |
e660718c63 | ||
![]() |
d701b8ff9b | ||
![]() |
f5038b5de3 | ||
![]() |
381a106b46 | ||
![]() |
90226043c7 | ||
![]() |
a74c0abdf5 | ||
![]() |
4eee7cd6ef | ||
![]() |
3f1a930046 | ||
![]() |
97863f62ca | ||
![]() |
58303de9f4 | ||
![]() |
06153de0aa | ||
![]() |
d8d53f76ca | ||
![]() |
d55cb6af5e | ||
![]() |
36c4c65609 | ||
![]() |
75d443934f | ||
![]() |
c538091fa8 | ||
![]() |
747224ac10 | ||
![]() |
af0c0e5bcd | ||
![]() |
7c92e0719c | ||
![]() |
40136785dd | ||
![]() |
67342e9c33 | ||
![]() |
ebb2cff2af | ||
![]() |
affd1bea49 | ||
![]() |
904fac958d | ||
![]() |
dc97da1276 | ||
![]() |
6a56c64d9a | ||
![]() |
79e5a884f5 | ||
![]() |
2a9d3bb168 | ||
![]() |
28c227157e | ||
![]() |
fe5d6ddf5c | ||
![]() |
8c8f4177d9 | ||
![]() |
ffb7f23dfd | ||
![]() |
f7a68cb503 | ||
![]() |
3bdc8316e9 | ||
![]() |
00d5d879c5 | ||
![]() |
735cc2272f | ||
![]() |
a38eefd2ab | ||
![]() |
04e026904f | ||
![]() |
6d7b13ac18 | ||
![]() |
1950585514 | ||
![]() |
99e580047c | ||
![]() |
6965618c34 | ||
![]() |
3b65a674d0 | ||
![]() |
13d3d0c552 | ||
![]() |
169e37cf31 | ||
![]() |
77d5c8fa64 | ||
![]() |
9d267f0803 | ||
![]() |
4fd9c6fd18 | ||
![]() |
e562e3d1bf | ||
![]() |
73ae6f480f | ||
![]() |
886bad1071 | ||
![]() |
87de73333a | ||
![]() |
19980b41b8 | ||
![]() |
1543f0a53e | ||
![]() |
12a1a85a2f | ||
![]() |
4550cfc6a0 | ||
![]() |
d3d5674436 | ||
![]() |
e76a8ff267 | ||
![]() |
c7ae851d39 | ||
![]() |
903277c222 | ||
![]() |
34b674aa6f | ||
![]() |
745043ca92 | ||
![]() |
6ed488cc65 | ||
![]() |
f2b0a982ac | ||
![]() |
797f29d40a | ||
![]() |
b42b0a8d3e | ||
![]() |
392037efd6 | ||
![]() |
3600aa4b75 | ||
![]() |
25a911d4cb | ||
![]() |
4157f8ca00 | ||
![]() |
48379a9d86 | ||
![]() |
50b5aa33b1 | ||
![]() |
fda96b2fdf | ||
![]() |
f53a13ae1e | ||
![]() |
f0c63b6ccd | ||
![]() |
e6d09ca748 | ||
![]() |
9e189e7d59 | ||
![]() |
ab140f578d | ||
![]() |
de1ab31bfd | ||
![]() |
2d5a3efc12 | ||
![]() |
46555d27b0 | ||
![]() |
c40ab3234d | ||
![]() |
19dc71f414 | ||
![]() |
069e42e763 | ||
![]() |
04fa5366ce | ||
![]() |
edf4fdda50 | ||
![]() |
6e30a1a32d | ||
![]() |
1042092144 | ||
![]() |
150dfd6cba | ||
![]() |
fd9fd9f0e7 | ||
![]() |
c9174cdecc | ||
![]() |
a20a0923b7 | ||
![]() |
91a308a62f | ||
![]() |
7bdc4291fc | ||
![]() |
af99f736bd | ||
![]() |
3a1ef872cd | ||
![]() |
4310d3ec88 | ||
![]() |
adf399fb95 | ||
![]() |
5b39c76aa8 | ||
![]() |
8423829714 | ||
![]() |
cc7c01132b | ||
![]() |
bf8abb3181 | ||
![]() |
494f0a4b1a | ||
![]() |
0dab9369ee | ||
![]() |
b15758da97 | ||
![]() |
3ba002460e | ||
![]() |
a559cbb590 | ||
![]() |
4648661ce6 | ||
![]() |
f68bf5a69f | ||
![]() |
90309f59ae | ||
![]() |
89937d9635 | ||
![]() |
3eeee3b983 | ||
![]() |
4e03e0fbe1 | ||
![]() |
dc252fe772 | ||
![]() |
30143ead97 | ||
![]() |
982f647845 | ||
![]() |
39b4aed85f | ||
![]() |
6f461564d5 | ||
![]() |
0c2f79b0b8 | ||
![]() |
0f330ef6a3 | ||
![]() |
9cb4ef4d7d | ||
![]() |
6b38336c7b | ||
![]() |
9aa261eaf6 | ||
![]() |
aa4d8f4925 | ||
![]() |
b6fe6ae062 | ||
![]() |
b14a131b43 | ||
![]() |
7ca01dc925 | ||
![]() |
e1a3037ffa | ||
![]() |
6bde3acc62 | ||
![]() |
d9914bf80a | ||
![]() |
7e9a33841d | ||
![]() |
afe146a620 | ||
![]() |
c7dd7208fb | ||
![]() |
8b937c91f4 | ||
![]() |
22016ffef9 | ||
![]() |
569e72a1c0 | ||
![]() |
740b1d3f50 | ||
![]() |
0aa56fbe2c | ||
![]() |
6f516b25af | ||
![]() |
a1591e8b0c | ||
![]() |
b0c5d6fc5a | ||
![]() |
0ac2ddd686 | ||
![]() |
53fc78d83e | ||
![]() |
980a8458d4 | ||
![]() |
f025f1d0cb | ||
![]() |
e95b159edd | ||
![]() |
227f035c2e | ||
![]() |
08ea0f270b | ||
![]() |
fd1926a1c8 | ||
![]() |
9c287ba36c | ||
![]() |
ef7cc20694 | ||
![]() |
cc21f42a14 | ||
![]() |
5c7bef31ca | ||
![]() |
fc050983c9 | ||
![]() |
92626393ec | ||
![]() |
ce00213c4a | ||
![]() |
92731848ac | ||
![]() |
2f44a4eb5a | ||
![]() |
d189c91c59 | ||
![]() |
eed6a982f7 | ||
![]() |
ab0199f238 | ||
![]() |
0a79ac75ff | ||
![]() |
d49a9e9e80 | ||
![]() |
0fc229dfc0 | ||
![]() |
02e7ac52e2 | ||
![]() |
cfc6ff0759 | ||
![]() |
277e59fbd5 | ||
![]() |
272303cb58 | ||
![]() |
819cd85a0e | ||
![]() |
d2e784e11c | ||
![]() |
8b91b3b749 | ||
![]() |
365af778b6 | ||
![]() |
3862826589 | ||
![]() |
2e4b57bf81 | ||
![]() |
55295ece9c | ||
![]() |
8a90173aa7 | ||
![]() |
72ff927a52 | ||
![]() |
9601eeb7c9 | ||
![]() |
91c1c6858a | ||
![]() |
101c994fec | ||
![]() |
ab19b01d43 | ||
![]() |
b3dc0097fd | ||
![]() |
6a951e2ff0 | ||
![]() |
b290e93441 | ||
![]() |
5799d534a9 | ||
![]() |
07b47606c1 | ||
![]() |
b946c2abff | ||
![]() |
25c3699862 | ||
![]() |
0397b02214 | ||
![]() |
7c23655c79 | ||
![]() |
fdd0d95554 | ||
![]() |
2a833aa23a | ||
![]() |
fec6dde00f | ||
![]() |
c81cc4055e | ||
![]() |
2d0706591b | ||
![]() |
f904933d68 | ||
![]() |
c227c1e2d9 | ||
![]() |
c23e84eb37 | ||
![]() |
637312e4f8 | ||
![]() |
ce3054713f | ||
![]() |
2c07fa1f77 | ||
![]() |
52280febf6 | ||
![]() |
f871130773 | ||
![]() |
25675ce2ba | ||
![]() |
c1f5d7c40c | ||
![]() |
4960569648 | ||
![]() |
eb60d364f6 | ||
![]() |
542008c61d | ||
![]() |
3da4dc71f1 | ||
![]() |
312664bd2d | ||
![]() |
69e2a57769 | ||
![]() |
6d202432ff | ||
![]() |
5b08adc4ff | ||
![]() |
86626ca44e | ||
![]() |
45c6ce2219 | ||
![]() |
ff63215d73 | ||
![]() |
d3a9b4943a | ||
![]() |
e0f6ee8b20 | ||
![]() |
edc7634007 | ||
![]() |
c0b7efea10 | ||
![]() |
2eb695f4c3 | ||
![]() |
d280f40885 | ||
![]() |
a9923fed4e | ||
![]() |
7a449f4686 | ||
![]() |
a8df91e91b | ||
![]() |
9e9ad3c005 | ||
![]() |
e5d828673e | ||
![]() |
df233f3e5e | ||
![]() |
784559f1b8 | ||
![]() |
ae51e57c75 | ||
![]() |
24e12bfbd4 | ||
![]() |
1386ca1669 | ||
![]() |
f7fcf4c23f | ||
![]() |
3b684e08ca | ||
![]() |
d7833afd35 | ||
![]() |
af7bcfc96a | ||
![]() |
842f6c289f | ||
![]() |
1e6112d5b0 | ||
![]() |
11e410c9c0 | ||
![]() |
0037b0b3fc | ||
![]() |
cd10a31a16 | ||
![]() |
ca10904484 | ||
![]() |
e3310e2358 | ||
![]() |
62714d995d | ||
![]() |
a8a21e05af | ||
![]() |
08316442cf | ||
![]() |
db98f5132b | ||
![]() |
a134a2b799 | ||
![]() |
7a556cf1fd | ||
![]() |
f11ea06c1a | ||
![]() |
d173bdf8e2 | ||
![]() |
832f9923b9 | ||
![]() |
ef810a9f36 | ||
![]() |
54c0eb7fdc | ||
![]() |
edacb9ec0b | ||
![]() |
b84d23564b | ||
![]() |
d5ba2e3f1c | ||
![]() |
385f5706d8 | ||
![]() |
2226594ade | ||
![]() |
a210a7b14d | ||
![]() |
25d3972810 | ||
![]() |
397016744e | ||
![]() |
4dbc70b745 | ||
![]() |
393cee7af5 | ||
![]() |
50df3862e9 | ||
![]() |
a80203f748 | ||
![]() |
cb350ecc65 | ||
![]() |
b2b676249d | ||
![]() |
ee23e8f49f | ||
![]() |
4aaf411cd2 | ||
![]() |
a63e25953f | ||
![]() |
48337b2e2c | ||
![]() |
3a96df4623 | ||
![]() |
4b9963757f | ||
![]() |
35500cc72b | ||
![]() |
3fbbb50ef7 | ||
![]() |
0aae5c48b4 | ||
![]() |
6cbacc8cb7 | ||
![]() |
2e50e0ffa1 | ||
![]() |
da449f9f5f | ||
![]() |
1b15dc3854 | ||
![]() |
86e13b088a | ||
![]() |
26a77e739d | ||
![]() |
c6f4229147 | ||
![]() |
fe64856be7 | ||
![]() |
9ba772b18f | ||
![]() |
efa20c26c9 | ||
![]() |
97f8149a2b | ||
![]() |
393e738ce6 | ||
![]() |
102b59a641 | ||
![]() |
f40eb50264 | ||
![]() |
ba53ea3306 | ||
![]() |
28c5d777a4 | ||
![]() |
b8816848a0 | ||
![]() |
1ea7a6f33f | ||
![]() |
aee3909a5f | ||
![]() |
a06ae82b56 | ||
![]() |
de4a80ef93 | ||
![]() |
d49a8f83df | ||
![]() |
3296fc15da | ||
![]() |
b525a80d28 | ||
![]() |
9f821b4cfa | ||
![]() |
c8d4b644bf | ||
![]() |
18c0634011 | ||
![]() |
b35b54cb80 | ||
![]() |
ee8044d162 | ||
![]() |
5b764953c0 | ||
![]() |
873068a187 | ||
![]() |
51dcc88f27 | ||
![]() |
0d3025b8cf | ||
![]() |
ce67a27c97 | ||
![]() |
dcd520d7eb | ||
![]() |
881db9b472 | ||
![]() |
93a1996491 | ||
![]() |
25adb7e303 | ||
![]() |
13fa735da0 | ||
![]() |
4315777638 | ||
![]() |
344405cdcb | ||
![]() |
f7b6246d41 | ||
![]() |
9000a3b70c | ||
![]() |
43563158d3 | ||
![]() |
1c6ee3f930 | ||
![]() |
25577379fc | ||
![]() |
923708f9f9 | ||
![]() |
eaf71be07c | ||
![]() |
3cf436c89e | ||
![]() |
01dc0a4b45 | ||
![]() |
978086c658 | ||
![]() |
3274235ac6 | ||
![]() |
6bb074eec3 | ||
![]() |
c450094659 | ||
![]() |
2d66b9751a | ||
![]() |
f69b9f857e | ||
![]() |
64984ee86a | ||
![]() |
2f87db9c0d | ||
![]() |
d0010217cd | ||
![]() |
a77f218a77 | ||
![]() |
290bf71659 | ||
![]() |
11b94593c2 | ||
![]() |
52f1e46343 | ||
![]() |
841e32bb64 | ||
![]() |
88d3186dc1 | ||
![]() |
f86eb6208f | ||
![]() |
c18b3f95b2 | ||
![]() |
23e24627d5 | ||
![]() |
d6cd041704 | ||
![]() |
e0f3bea9ad | ||
![]() |
436dcc977f | ||
![]() |
b667cef262 | ||
![]() |
8e6d1d5f07 | ||
![]() |
9b27ed4798 | ||
![]() |
b8e391c005 | ||
![]() |
827fb19df7 | ||
![]() |
80897001a5 | ||
![]() |
8a1cdab27e | ||
![]() |
e98addf33a | ||
![]() |
0b9cb185fa | ||
![]() |
061f10a059 | ||
![]() |
c45bca6ce9 | ||
![]() |
17d1b0b8d6 | ||
![]() |
5a0f53654f | ||
![]() |
0e54e650e3 | ||
![]() |
6a73d3f8f3 | ||
![]() |
47b76a49d8 | ||
![]() |
6bc4e40773 | ||
![]() |
26a7a7d7b8 | ||
![]() |
bb24aa46d1 | ||
![]() |
efd55ffe97 | ||
![]() |
1354465562 | ||
![]() |
1e6b13f9d5 | ||
![]() |
f56645ecfe | ||
![]() |
efc42481da | ||
![]() |
26f27d32a1 | ||
![]() |
14d6600fb5 | ||
![]() |
d8c5b74e09 | ||
![]() |
d680544b69 | ||
![]() |
ab37e18bc3 | ||
![]() |
dbb528762e | ||
![]() |
ecdd0c54bd | ||
![]() |
f7d37a49d6 | ||
![]() |
ed006b707c | ||
![]() |
bb3f1aa998 | ||
![]() |
d280d4ecad | ||
![]() |
b375b13950 | ||
![]() |
60e588440f | ||
![]() |
5ee59a4f4a | ||
![]() |
a8b443fe5f | ||
![]() |
996c407393 | ||
![]() |
85533a36e9 | ||
![]() |
d7ef7f0399 | ||
![]() |
50a5c7984d | ||
![]() |
cc3b020d88 | ||
![]() |
957462b61c | ||
![]() |
c4c8f521ff | ||
![]() |
7a0a6077cf | ||
![]() |
586820c34d | ||
![]() |
18cd52cfa1 | ||
![]() |
0e13796882 | ||
![]() |
daa52d62fa | ||
![]() |
e3e8d944b2 | ||
![]() |
14cbd44d9b | ||
![]() |
cb7d75202b | ||
![]() |
bc78432e62 | ||
![]() |
efa39ee664 | ||
![]() |
1440a47d53 | ||
![]() |
81dd3c10a7 | ||
![]() |
8017918063 | ||
![]() |
75ce58d0c6 | ||
![]() |
bb66f75027 | ||
![]() |
3ef62472bd | ||
![]() |
e282a74f7a | ||
![]() |
10091811f7 | ||
![]() |
67ab5a749a | ||
![]() |
2dd054d602 | ||
![]() |
966cea3d8b | ||
![]() |
b56556f37b | ||
![]() |
899f6d18d6 | ||
![]() |
449a5d6339 | ||
![]() |
4847c83cb8 | ||
![]() |
69d99079b1 | ||
![]() |
fee5ea8411 | ||
![]() |
5c4cb50628 | ||
![]() |
2ebc3a982a | ||
![]() |
59f614a41b | ||
![]() |
ec1bd69605 | ||
![]() |
40193752c4 | ||
![]() |
eebda2427e | ||
![]() |
e25a69936e | ||
![]() |
443fb3f152 | ||
![]() |
fb0cfbe0bb | ||
![]() |
bd67b4ca13 | ||
![]() |
6c96c70b28 | ||
![]() |
b70e0166bd | ||
![]() |
60cdd252ef | ||
![]() |
c942b490ab | ||
![]() |
732b365683 | ||
![]() |
dd351f64fb | ||
![]() |
5730cd3dde | ||
![]() |
b52c0bf08e | ||
![]() |
587390d066 | ||
![]() |
21c035b8d5 | ||
![]() |
6d35305d7d | ||
![]() |
271635491a | ||
![]() |
aa002369cb | ||
![]() |
7c79c3b4c3 | ||
![]() |
ac51878186 | ||
![]() |
3cb7ef05d9 | ||
![]() |
7356c3b863 | ||
![]() |
11d964649c | ||
![]() |
8d940ad841 | ||
![]() |
8e31c30ec7 | ||
![]() |
82e5b2c5d7 | ||
![]() |
2f3166aa54 | ||
![]() |
8234e80931 | ||
![]() |
3bfc3ee7ae | ||
![]() |
732b1d146e | ||
![]() |
bf22684e2d | ||
![]() |
1ec12e3d88 | ||
![]() |
50c2d8f32f | ||
![]() |
b99a81cb25 | ||
![]() |
c1f3758aa2 | ||
![]() |
67a436b639 | ||
![]() |
70c26b6ed2 | ||
![]() |
0d70223a48 | ||
![]() |
caf8b5c3c5 | ||
![]() |
5506e0d58e | ||
![]() |
ddfadbc474 | ||
![]() |
3c3414a7d3 | ||
![]() |
9762cf95e3 | ||
![]() |
bc4550c1f9 | ||
![]() |
fba8f776a1 | ||
![]() |
9e07549ecb | ||
![]() |
0a0dc13030 | ||
![]() |
5ba31ab14f | ||
![]() |
c50bd6af89 | ||
![]() |
391fca9e83 | ||
![]() |
5717ea7f5c | ||
![]() |
7ea1b690f2 | ||
![]() |
d489e35782 | ||
![]() |
8d4f258494 | ||
![]() |
dc73a18ca4 | ||
![]() |
9cf284aefa | ||
![]() |
3242376d19 | ||
![]() |
2ea03af559 | ||
![]() |
1b5d31941e | ||
![]() |
9cb2b48c1e | ||
![]() |
6eada92966 | ||
![]() |
2ccdb74d20 | ||
![]() |
d39d92cce8 | ||
![]() |
0335367c75 | ||
![]() |
fa553128a4 | ||
![]() |
b3a1341545 | ||
![]() |
4b5f31ac95 | ||
![]() |
ad94c29659 | ||
![]() |
ec91c120b1 | ||
![]() |
9f0f910a83 | ||
![]() |
fbbba7a3df | ||
![]() |
6973ba4244 | ||
![]() |
e8b5ee2ff9 | ||
![]() |
4d93187e58 | ||
![]() |
efb66b4d2f | ||
![]() |
e4156e19b8 | ||
![]() |
f9b36cd5be | ||
![]() |
5cc0895c56 | ||
![]() |
07b37abcb3 | ||
![]() |
e7329b9660 | ||
![]() |
eddeca2942 | ||
![]() |
661aa20c09 | ||
![]() |
6c3cc794a4 | ||
![]() |
12939b91b3 | ||
![]() |
f72a34f25b | ||
![]() |
c060358cd8 | ||
![]() |
f29bd47911 | ||
![]() |
59275eeb84 | ||
![]() |
bc4c3c4ef8 | ||
![]() |
e3dd47ba6e | ||
![]() |
81941ff335 | ||
![]() |
9a2847dbee | ||
![]() |
f0856c862f | ||
![]() |
6881ba956a | ||
![]() |
bfe42734bc | ||
![]() |
c3368167d0 | ||
![]() |
5ce1685b5b | ||
![]() |
aa97dbdbb6 | ||
![]() |
42eb4b2779 | ||
![]() |
0261d701a7 | ||
![]() |
08c5b11689 | ||
![]() |
1cd8eba098 | ||
![]() |
07b2255426 | ||
![]() |
d95340edbc | ||
![]() |
a5e2708eae | ||
![]() |
c8a410d358 | ||
![]() |
473f767465 | ||
![]() |
ffbb09e1d4 | ||
![]() |
057f4b4bb5 | ||
![]() |
d9587e8b06 | ||
![]() |
50c9de6178 | ||
![]() |
7a072164a2 | ||
![]() |
930401541d | ||
![]() |
081fced4bd | ||
![]() |
22ad3a86a9 | ||
![]() |
dd3ae65bd2 | ||
![]() |
2a88d7d9c9 | ||
![]() |
6f05a43f32 | ||
![]() |
79f9957b68 | ||
![]() |
32a8142f9c | ||
![]() |
32b30606e5 | ||
![]() |
7abafb01ea | ||
![]() |
4464320757 | ||
![]() |
4bbd5f32b9 | ||
![]() |
4077486b86 | ||
![]() |
666481d8b2 | ||
![]() |
61db35ac8f | ||
![]() |
707e197625 | ||
![]() |
958e05a001 | ||
![]() |
5b499efd23 | ||
![]() |
d7d60f9d4c | ||
![]() |
7256c99e29 | ||
![]() |
a8d6055b4e | ||
![]() |
c51d6f46d4 | ||
![]() |
73476180d4 | ||
![]() |
b3cc1fa582 | ||
![]() |
19a804d5bf | ||
![]() |
478e264817 | ||
![]() |
df3d9099b6 | ||
![]() |
2731ffaf10 | ||
![]() |
14c82ac94d | ||
![]() |
52e6a216f4 | ||
![]() |
1a8c549389 | ||
![]() |
62760a9bf5 | ||
![]() |
5d78229e1e | ||
![]() |
d7bd665bee | ||
![]() |
447be67f78 | ||
![]() |
5fec16153b | ||
![]() |
53c34b5726 | ||
![]() |
2b49653f21 | ||
![]() |
a0aa6b9cc7 | ||
![]() |
f5a89cc38f | ||
![]() |
2c3eed8d96 | ||
![]() |
2ad9f36706 | ||
![]() |
850da34778 | ||
![]() |
23c1a0ba4d | ||
![]() |
71094cb283 | ||
![]() |
35722acb3d | ||
![]() |
8870aa6e63 | ||
![]() |
5a4cf8a003 | ||
![]() |
c732a02b38 | ||
![]() |
c6ac06b51c | ||
![]() |
c378f0961c | ||
![]() |
a9780ccf96 | ||
![]() |
247b4e274d | ||
![]() |
9d290ae234 | ||
![]() |
ed9844b2ec | ||
![]() |
87bca3601d | ||
![]() |
bb3a123b8d | ||
![]() |
86d310c741 | ||
![]() |
8ed650f57a | ||
![]() |
13270dee2d | ||
![]() |
cf36767f03 | ||
![]() |
247f04557c | ||
![]() |
3c154955b2 | ||
![]() |
612981bedb | ||
![]() |
a7466a7291 | ||
![]() |
fb50d429b4 | ||
![]() |
8ec9cd21b4 | ||
![]() |
5861e4160c | ||
![]() |
ec763c69a7 | ||
![]() |
1cfd5ca948 | ||
![]() |
12b96e40a5 | ||
![]() |
626416ed02 | ||
![]() |
6166a51552 | ||
![]() |
6f1f1ba744 | ||
![]() |
dd3956f5f4 | ||
![]() |
52898ac83b | ||
![]() |
fe1129c2cf | ||
![]() |
7f086b21c8 | ||
![]() |
b225b55e8d | ||
![]() |
f95bf41824 | ||
![]() |
d492512d9e | ||
![]() |
1c672e55f5 | ||
![]() |
9d858dcf0f | ||
![]() |
584db6c301 | ||
![]() |
a29280a1fe | ||
![]() |
315bde6f1b | ||
![]() |
2256697323 | ||
![]() |
ee2338a33b | ||
![]() |
94e12ec404 | ||
![]() |
26032d6b77 | ||
![]() |
adb9723d62 | ||
![]() |
b901bb6c75 | ||
![]() |
fa62529d82 | ||
![]() |
cde7ff5d2f | ||
![]() |
82c95dd82d | ||
![]() |
778221c0af | ||
![]() |
f8f2585164 | ||
![]() |
04d36122bd | ||
![]() |
dfc00ed8c1 | ||
![]() |
262a1d09c6 | ||
![]() |
5e76a97bc4 | ||
![]() |
aa367bcd1d | ||
![]() |
fe68930e9a | ||
![]() |
3a0c71175b | ||
![]() |
8fc206073b | ||
![]() |
34d5564abc | ||
![]() |
c1fc5b88b3 | ||
![]() |
a1098a921c | ||
![]() |
a1a3019d1e | ||
![]() |
34be8b75ad | ||
![]() |
cedc96bdd7 | ||
![]() |
457d61fa9a | ||
![]() |
35902407b3 | ||
![]() |
1f7bfb4737 | ||
![]() |
6ac5142b41 | ||
![]() |
39110d9da9 | ||
![]() |
ef958f97a1 | ||
![]() |
832028e92d | ||
![]() |
f0edc7ba00 | ||
![]() |
d6c2188f2d | ||
![]() |
bc95219bf6 | ||
![]() |
3e49fd6967 | ||
![]() |
846ccd3aac | ||
![]() |
9babb6283b | ||
![]() |
62c8af2a93 | ||
![]() |
3850e97446 | ||
![]() |
397a546095 | ||
![]() |
d8c5160349 | ||
![]() |
3a5d24eb7a | ||
![]() |
a3b908e255 | ||
![]() |
0e49a5d9b0 | ||
![]() |
958ff0f3bf | ||
![]() |
773cbc92eb | ||
![]() |
b9f1013f37 | ||
![]() |
30c5495b21 | ||
![]() |
835e14b8b2 | ||
![]() |
fb03fe4d26 | ||
![]() |
4a17a1e713 | ||
![]() |
c60ceaf932 | ||
![]() |
8ad335cf47 | ||
![]() |
14308f4fba | ||
![]() |
fe8d6392d5 | ||
![]() |
dd526693f5 | ||
![]() |
42aea53307 | ||
![]() |
3aa579f232 | ||
![]() |
9cd7e49daf | ||
![]() |
9f22564ca3 | ||
![]() |
af46bcdace | ||
![]() |
c27c7bbe83 | ||
![]() |
814a55809b | ||
![]() |
29b0dd0725 | ||
![]() |
f7f4a03abb | ||
![]() |
f11a6a6b87 | ||
![]() |
6207c853ef | ||
![]() |
c183176fd3 | ||
![]() |
15663796ad | ||
![]() |
e670024f5c | ||
![]() |
bdf0194fc8 | ||
![]() |
4ba0e97b95 | ||
![]() |
d85dbe429d | ||
![]() |
d99ba08d01 | ||
![]() |
b831a0b3f7 | ||
![]() |
b538d56591 | ||
![]() |
7920cd9d3d | ||
![]() |
28468e134c | ||
![]() |
1741e48d59 | ||
![]() |
94a067d7c8 | ||
![]() |
3f10b29869 | ||
![]() |
ca111771c2 | ||
![]() |
dadf6f0ed4 | ||
![]() |
fc56d1690d | ||
![]() |
aa723a70c2 | ||
![]() |
73e141a4a3 | ||
![]() |
620edfa347 | ||
![]() |
4e192f760d | ||
![]() |
44926d3519 | ||
![]() |
b41f8164b8 | ||
![]() |
bfd9ab6b8f | ||
![]() |
9ea0f2ec29 | ||
![]() |
4cee376878 | ||
![]() |
4e8f982ca2 | ||
![]() |
eb276c7403 | ||
![]() |
a68e96400b | ||
![]() |
0f3f78cde7 | ||
![]() |
d08fa01110 | ||
![]() |
cdf04d695c | ||
![]() |
71bc4c45bc | ||
![]() |
55a13ca1de | ||
![]() |
92dd62975e | ||
![]() |
040f70471c | ||
![]() |
f08532dfb0 | ||
![]() |
7eecfe9e27 | ||
![]() |
670ed44963 | ||
![]() |
f2030789d1 | ||
![]() |
ee11889431 | ||
![]() |
1fb18b092d | ||
![]() |
6169a5d3df | ||
![]() |
51250ca45f | ||
![]() |
bfe5871e9e | ||
![]() |
0cc244c516 | ||
![]() |
767ac1632e | ||
![]() |
1d1bad23e3 | ||
![]() |
73b4584575 | ||
![]() |
bbf85ae6d2 | ||
![]() |
90fac6b206 | ||
![]() |
9f527f10e1 | ||
![]() |
fc6df69e41 | ||
![]() |
3b5a148cdc | ||
![]() |
4e27add5b7 | ||
![]() |
c3e34f8850 | ||
![]() |
f36c87b301 | ||
![]() |
830d0daa38 | ||
![]() |
fd62142b21 | ||
![]() |
6501314616 | ||
![]() |
fef5ab7255 | ||
![]() |
0c6c61b654 | ||
![]() |
774292d8ba | ||
![]() |
6969a12fae | ||
![]() |
c52b23af57 | ||
![]() |
e08e58485e | ||
![]() |
b9bc2b01fd | ||
![]() |
b223bb8da8 | ||
![]() |
61ce012fb5 | ||
![]() |
f0fe0db10c | ||
![]() |
93793fe723 | ||
![]() |
46f3b595a9 | ||
![]() |
639690bb50 | ||
![]() |
90b6dbdf55 | ||
![]() |
640c699042 | ||
![]() |
ad677afa81 | ||
![]() |
f46fd6f2d5 | ||
![]() |
373b222929 | ||
![]() |
99ab41fd76 | ||
![]() |
c921c8f586 | ||
![]() |
4707842642 | ||
![]() |
dabf610764 | ||
![]() |
b44dd4d3c5 | ||
![]() |
2d4b5e51f1 | ||
![]() |
c55df0e803 | ||
![]() |
b14490d6aa | ||
![]() |
d67f3d3181 | ||
![]() |
2f0254a2c8 | ||
![]() |
fa058a5ca8 | ||
![]() |
25d1b2a24d | ||
![]() |
363e426e9f | ||
![]() |
d04cc1d8ac | ||
![]() |
dda94cdfbc | ||
![]() |
e754ee9cb4 | ||
![]() |
7eb5c8a38e | ||
![]() |
1ab8302254 | ||
![]() |
29238e54e3 | ||
![]() |
20c7f14b3c | ||
![]() |
b480903426 | ||
![]() |
235f1a5a59 | ||
![]() |
d59afb21be | ||
![]() |
5ba43eb56c | ||
![]() |
9eb84d6ad5 | ||
![]() |
374acf8119 | ||
![]() |
ab19677a6c | ||
![]() |
e8462f4250 | ||
![]() |
1fb94dee18 | ||
![]() |
e4dae982d2 | ||
![]() |
f5c92cb627 | ||
![]() |
bdcf1d3a83 | ||
![]() |
e827540a6d | ||
![]() |
7f019d3880 | ||
![]() |
716fe07e84 | ||
![]() |
0e9c310d1d | ||
![]() |
7308ac0e1f | ||
![]() |
a853a92765 | ||
![]() |
562ef81389 | ||
![]() |
8fe07b196b | ||
![]() |
7f67df2468 | ||
![]() |
47fb3a644c | ||
![]() |
e44f892cb0 | ||
![]() |
6932b3deb7 | ||
![]() |
800b151024 | ||
![]() |
47d8e59938 | ||
![]() |
56f8993bd7 | ||
![]() |
432a92173a | ||
![]() |
2779691cd9 | ||
![]() |
d2d556ddf6 | ||
![]() |
0895b5c6ee | ||
![]() |
7168572e74 | ||
![]() |
c2da12939e | ||
![]() |
b8d74c6ae0 | ||
![]() |
e56c4304a1 | ||
![]() |
9fd4e4ab87 | ||
![]() |
d7cddd14fa | ||
![]() |
67a6857ca6 | ||
![]() |
57f389646c | ||
![]() |
f3a19f48d8 | ||
![]() |
19852ed180 | ||
![]() |
5a33a51076 | ||
![]() |
48e0bc28f8 | ||
![]() |
e98ec386cb | ||
![]() |
9680fd115b | ||
![]() |
54f5c3115c | ||
![]() |
1117ea1b3e | ||
![]() |
ff78f687d8 | ||
![]() |
31b57e2991 | ||
![]() |
8ada51158f | ||
![]() |
6cb5360c88 | ||
![]() |
70601db76f | ||
![]() |
7b69d61540 | ||
![]() |
13bf214a3c | ||
![]() |
47ea64c30a | ||
![]() |
e94473a1ce | ||
![]() |
a7818e9b11 | ||
![]() |
f94adbf039 | ||
![]() |
ec13227fc6 | ||
![]() |
a530cca2c5 | ||
![]() |
0292bc418d | ||
![]() |
e99cd74cca | ||
![]() |
dcabf55882 | ||
![]() |
35dc7faab6 | ||
![]() |
c5b584e3d8 | ||
![]() |
09b68de041 | ||
![]() |
7c7cc0fce0 | ||
![]() |
3f7c88108c | ||
![]() |
f134746c9c | ||
![]() |
b5d6484991 | ||
![]() |
78481e010e | ||
![]() |
9d72eeeeac | ||
![]() |
f85fdd3a97 | ||
![]() |
f6bd485863 | ||
![]() |
5cdaa424ee | ||
![]() |
0c3a62297a | ||
![]() |
a097577e29 | ||
![]() |
f7e716c826 | ||
![]() |
84996ea88c | ||
![]() |
e6371ec197 | ||
![]() |
971c63a636 | ||
![]() |
5a67353dc3 | ||
![]() |
eaefecb91d | ||
![]() |
8d569815e6 | ||
![]() |
2d48c86e61 | ||
![]() |
a178c0f400 | ||
![]() |
cf105cf01d | ||
![]() |
3b93efdf5c | ||
![]() |
28ff69b51b | ||
![]() |
4ddd3ee772 | ||
![]() |
92499f6260 | ||
![]() |
3d9bc77fce | ||
![]() |
c3ade4dce1 | ||
![]() |
9470c3a44b | ||
![]() |
89b4eaf391 | ||
![]() |
79dcab4ef5 | ||
![]() |
542a52c510 | ||
![]() |
10b0d6333f | ||
![]() |
1fcf046c81 | ||
![]() |
cc72d8b11b | ||
![]() |
c4493ebc90 | ||
![]() |
3d9b1bb177 | ||
![]() |
ea33c7d896 | ||
![]() |
768180c456 | ||
![]() |
dad6f97cce | ||
![]() |
273ae4aecd | ||
![]() |
b5f8bfa28e | ||
![]() |
959562661f | ||
![]() |
80abd0ac2c | ||
![]() |
19eefebe95 | ||
![]() |
087a9daf34 | ||
![]() |
a7be1f3430 | ||
![]() |
c373db4f86 | ||
![]() |
3bc21faeaf | ||
![]() |
f9515f10cd | ||
![]() |
0b387c5116 | ||
![]() |
149b590413 | ||
![]() |
282f5f92ff | ||
![]() |
afedce1b0e | ||
![]() |
061d67ee4b | ||
![]() |
36056e75d7 | ||
![]() |
d04bfbcdea | ||
![]() |
ecc2f1f544 | ||
![]() |
a11266471c | ||
![]() |
302362c70d | ||
![]() |
efd53e567c | ||
![]() |
0002e008bb | ||
![]() |
96af83a4ed | ||
![]() |
00aa26cd1a | ||
![]() |
3cad54b215 | ||
![]() |
a04d3198a8 | ||
![]() |
aaa15a2733 | ||
![]() |
cae698b705 | ||
![]() |
c233243948 | ||
![]() |
f045361b49 | ||
![]() |
441c7a89a7 | ||
![]() |
7ec4cbd841 | ||
![]() |
1ea577ef12 | ||
![]() |
eedf5367fc | ||
![]() |
fe4f41501f | ||
![]() |
f11ad91249 | ||
![]() |
1c4a761478 | ||
![]() |
c3c14ccfbc | ||
![]() |
9824151e62 | ||
![]() |
d7ad742ba3 | ||
![]() |
087c41190e | ||
![]() |
69bc8a135b | ||
![]() |
6a344c7a52 | ||
![]() |
b5031fdee5 | ||
![]() |
15e5501ddd | ||
![]() |
5974eed4aa | ||
![]() |
8a4c84e7dd | ||
![]() |
c4e6dfbbbd | ||
![]() |
e52f3543a7 | ||
![]() |
37dc516ea2 | ||
![]() |
6dce566595 | ||
![]() |
9ff3a45690 | ||
![]() |
281168fd52 | ||
![]() |
154de1a0f9 | ||
![]() |
4e8d718609 | ||
![]() |
3784061935 | ||
![]() |
8ad7ec6682 | ||
![]() |
cd133e8240 | ||
![]() |
2147f1d53d | ||
![]() |
a062ec2dd9 | ||
![]() |
763bf34e19 | ||
![]() |
ee99856b8f | ||
![]() |
b653ae2a66 | ||
![]() |
ab55d54c45 | ||
![]() |
b52ad5c32e | ||
![]() |
13e97ab2c9 | ||
![]() |
13b4e6333c | ||
![]() |
6916e22b09 | ||
![]() |
27e2adecab | ||
![]() |
4441f42dea | ||
![]() |
4ccc21ca85 | ||
![]() |
8e86a78666 | ||
![]() |
42ddbb6eb3 | ||
![]() |
08882c63df | ||
![]() |
42ee56ecd4 | ||
![]() |
2c91363745 | ||
![]() |
893294e6b8 | ||
![]() |
6f826c5546 | ||
![]() |
3a67124f90 | ||
![]() |
d17f698453 | ||
![]() |
733bc26ed1 | ||
![]() |
d63221d8b6 | ||
![]() |
5010c91c79 | ||
![]() |
55e14b9b21 | ||
![]() |
34b397073f | ||
![]() |
ccd685b60d | ||
![]() |
2d1fb9c1a5 | ||
![]() |
cd644320f4 | ||
![]() |
1f339b37bd | ||
![]() |
69896f4c8b | ||
![]() |
c815106d62 | ||
![]() |
67947fff6c | ||
![]() |
17d7c3c094 | ||
![]() |
290bdf4361 | ||
![]() |
1dbf5dca10 | ||
![]() |
408f6dfee3 | ||
![]() |
767eccd1c8 | ||
![]() |
a4f2d0e37a | ||
![]() |
dddb091d77 | ||
![]() |
195a12a3dc | ||
![]() |
4720e8f2c5 | ||
![]() |
0d9528932c | ||
![]() |
1534731cdf | ||
![]() |
8d0fa5be65 | ||
![]() |
d8b0ab9436 | ||
![]() |
0a270e0870 | ||
![]() |
a8030f9e32 | ||
![]() |
14d9874b68 | ||
![]() |
18fb4effb1 | ||
![]() |
b37b409994 | ||
![]() |
767591973c | ||
![]() |
da3a24ad7e | ||
![]() |
8a81b0777a | ||
![]() |
18c2b4108d | ||
![]() |
c9320eb7a1 | ||
![]() |
0abdbc0093 | ||
![]() |
9586af91e5 | ||
![]() |
887e190de8 | ||
![]() |
3851ee4eb2 | ||
![]() |
763e5ba82c | ||
![]() |
788f6569d8 | ||
![]() |
d5adc6ee4b | ||
![]() |
8ce05dfc62 | ||
![]() |
245473c1ac | ||
![]() |
8610b190e8 | ||
![]() |
efdde960d5 | ||
![]() |
06f0ee4c91 | ||
![]() |
aa7692b7dc | ||
![]() |
508b796a69 | ||
![]() |
670c691196 | ||
![]() |
50e0d2b4ff | ||
![]() |
182713d02d | ||
![]() |
fc19e1d34a | ||
![]() |
a6d71eb555 | ||
![]() |
ad78287c1f | ||
![]() |
0783bf1dc7 | ||
![]() |
8533b8a628 | ||
![]() |
a26290018c | ||
![]() |
08ee37afc1 | ||
![]() |
fe03d8d7fd | ||
![]() |
d9a9fbb242 | ||
![]() |
59fb97d874 | ||
![]() |
04c42f03e1 | ||
![]() |
61ddeeca2b | ||
![]() |
6f1f968954 | ||
![]() |
1fd9c592e2 | ||
![]() |
59b708e3f7 | ||
![]() |
fe878a88d1 | ||
![]() |
598d51c9e7 | ||
![]() |
fe1beec09a | ||
![]() |
cee51af9fd | ||
![]() |
02cd5d2f2f | ||
![]() |
7070a0d410 | ||
![]() |
09ffc2b652 | ||
![]() |
9dee601ba3 | ||
![]() |
4e353956db | ||
![]() |
6e55a9fa81 | ||
![]() |
0acc067205 | ||
![]() |
9d8f9e5358 | ||
![]() |
dd8a683e9e | ||
![]() |
8b64325e89 | ||
![]() |
8138c76c1d | ||
![]() |
4d857b7937 | ||
![]() |
82f2e47476 | ||
![]() |
e429ee533f | ||
![]() |
d65be7772d | ||
![]() |
c9bbdc37a1 | ||
![]() |
6b90f831ad | ||
![]() |
b0ed2efdf9 | ||
![]() |
47dd074c36 | ||
![]() |
c31dffc605 | ||
![]() |
f2aa79f49d | ||
![]() |
a0d79d5871 | ||
![]() |
df671b2869 | ||
![]() |
a0eb8baf72 | ||
![]() |
3671e4d452 | ||
![]() |
e151be6b89 | ||
![]() |
816410f2d4 | ||
![]() |
2139107f6b | ||
![]() |
dd2c213ac5 | ||
![]() |
e8a6cb340e | ||
![]() |
c5f4fb5762 | ||
![]() |
cad8eb679c | ||
![]() |
a7e00e19e2 | ||
![]() |
6688b81ece | ||
![]() |
789db0d822 | ||
![]() |
819e49aa17 | ||
![]() |
ebab25058a | ||
![]() |
d8b850f740 | ||
![]() |
3976210485 | ||
![]() |
981e91f012 | ||
![]() |
c3f94358eb | ||
![]() |
7b2f51aad5 | ||
![]() |
34cac93f9a | ||
![]() |
f0d94f06ed | ||
![]() |
0be79e846c | ||
![]() |
2e72aa8a47 | ||
![]() |
9a2a14f588 | ||
![]() |
d22ffb926d | ||
![]() |
78297efeab | ||
![]() |
fb22d6f518 | ||
![]() |
12abf74620 | ||
![]() |
2710e37caf | ||
![]() |
14e76b5a72 | ||
![]() |
0e64fe2b21 | ||
![]() |
be247b8cc9 | ||
![]() |
64bb822e41 | ||
![]() |
3a9587797d | ||
![]() |
2d02111e06 | ||
![]() |
874b37c917 | ||
![]() |
b75917eb9f | ||
![]() |
725db44392 | ||
![]() |
32983da29c | ||
![]() |
8ca5360454 | ||
![]() |
aac5bccc54 | ||
![]() |
8fdefdc2bd | ||
![]() |
0d85fd775c | ||
![]() |
a1755c1c08 | ||
![]() |
5f6de9caf0 | ||
![]() |
432bbe0db8 | ||
![]() |
483c97a484 | ||
![]() |
ae491f910b | ||
![]() |
b38c6eab7c | ||
![]() |
f40118e58b | ||
![]() |
04a2e24a89 | ||
![]() |
97d7a60f68 | ||
![]() |
d1ddfadd0d | ||
![]() |
4152d3d796 | ||
![]() |
79bb2e3d40 | ||
![]() |
a34ebd3c7e | ||
![]() |
fb12a1bc8e | ||
![]() |
46e9c69d8e | ||
![]() |
f096ffaa43 | ||
![]() |
98ea3664f2 | ||
![]() |
b79af7f6d3 | ||
![]() |
39884d8199 | ||
![]() |
0c8e31708a | ||
![]() |
b507c8e1bb | ||
![]() |
e954afa1c2 | ||
![]() |
61f4e3f9d5 | ||
![]() |
3a898e2b61 | ||
![]() |
ed3442ada6 | ||
![]() |
5d42e51d3e | ||
![]() |
ccb2791a0d | ||
![]() |
784d107472 | ||
![]() |
4611de3f3d | ||
![]() |
cd7b1bf649 | ||
![]() |
05045ee9f7 | ||
![]() |
7cf2359a62 | ||
![]() |
f7c4df8d04 | ||
![]() |
60eaca8301 | ||
![]() |
de50b9b76c | ||
![]() |
420c3881c2 | ||
![]() |
be33d0dd67 | ||
![]() |
8e18162491 | ||
![]() |
4c60432229 | ||
![]() |
be8adaf142 | ||
![]() |
8183c0785c | ||
![]() |
d2738fda73 | ||
![]() |
6bea6a887d | ||
![]() |
add8c3d120 | ||
![]() |
da31539306 | ||
![]() |
b974b501f4 | ||
![]() |
1d648f9755 | ||
![]() |
1a45b909c3 | ||
![]() |
b928e865b7 | ||
![]() |
cd5d2da2d2 | ||
![]() |
d954fd5498 | ||
![]() |
84c66d5eae | ||
![]() |
9fe43e2f0d | ||
![]() |
b4051e48d3 | ||
![]() |
879aa33cbe | ||
![]() |
1a5db4b385 | ||
![]() |
dfb7ab3433 | ||
![]() |
09110a0a76 | ||
![]() |
3251ebffdb | ||
![]() |
d1410f4636 | ||
![]() |
dc0b214bb5 | ||
![]() |
c86343dd1d | ||
![]() |
3de76b848f | ||
![]() |
b689c0d3b1 | ||
![]() |
bebc32b11d | ||
![]() |
9f438f0805 | ||
![]() |
a9d4341a26 | ||
![]() |
22bf2ced21 | ||
![]() |
761c184c8f | ||
![]() |
126648e597 | ||
![]() |
01e8159801 | ||
![]() |
510ad0ce9a | ||
![]() |
8e5ebd5512 | ||
![]() |
4983e760bb | ||
![]() |
f032b47744 | ||
![]() |
58ece71c7a | ||
![]() |
93dd1fea2d | ||
![]() |
61ddc73d87 | ||
![]() |
0f57110de0 | ||
![]() |
14cb64df6b | ||
![]() |
53d8f365c7 | ||
![]() |
03cd878d94 | ||
![]() |
09ab193239 | ||
![]() |
32bb4d36bb | ||
![]() |
0eaa0c47e4 | ||
![]() |
68edbab74f | ||
![]() |
0a7b2004d2 | ||
![]() |
064baf31ad | ||
![]() |
b5f96b50c0 | ||
![]() |
12494acdf7 | ||
![]() |
f3950e20a0 | ||
![]() |
4ab827bf0f | ||
![]() |
eb5417fad5 | ||
![]() |
64e8d2b8b3 | ||
![]() |
3082e4c5cc | ||
![]() |
b0534b7f85 | ||
![]() |
1aec79d726 | ||
![]() |
a249c8e643 | ||
![]() |
e43da5569c | ||
![]() |
f219744fdd | ||
![]() |
bd76193eb5 | ||
![]() |
b5c11370d6 | ||
![]() |
37444c5eef | ||
![]() |
431cba20ca | ||
![]() |
9893f18ca5 | ||
![]() |
3c1f20a6d1 | ||
![]() |
ce4c8a4e47 | ||
![]() |
735fd761cd | ||
![]() |
5facec8ed2 | ||
![]() |
e145667a81 | ||
![]() |
3d0632e916 | ||
![]() |
b941439461 | ||
![]() |
58660cc9f2 | ||
![]() |
8421bcc5d2 | ||
![]() |
d66c11e786 | ||
![]() |
ad9b409cf9 | ||
![]() |
668e9428f8 | ||
![]() |
294079d55e | ||
![]() |
9854d37916 | ||
![]() |
26321c5dba | ||
![]() |
185c53bd70 | ||
![]() |
7632bed1fe | ||
![]() |
91fb235030 | ||
![]() |
36392832d5 | ||
![]() |
543e15b846 | ||
![]() |
e76baa9cb4 | ||
![]() |
ac15073a52 | ||
![]() |
9b9d002f9b | ||
![]() |
25d9ef33c5 | ||
![]() |
6b8a50eb92 | ||
![]() |
81aab1e159 | ||
![]() |
67f3c468a1 | ||
![]() |
77fa5cf784 | ||
![]() |
ff92b70a7f | ||
![]() |
25378de222 | ||
![]() |
b2ff14f669 | ||
![]() |
9b073db24d | ||
![]() |
c53a00eb48 | ||
![]() |
5e0dced989 | ||
![]() |
bcd61354a8 | ||
![]() |
109cc90e34 | ||
![]() |
d0c94a0a56 | ||
![]() |
69d309f6a0 | ||
![]() |
6d45fea773 | ||
![]() |
6ee3ff63e4 | ||
![]() |
1ab971a2da | ||
![]() |
4a927e45a6 | ||
![]() |
16130b79db | ||
![]() |
72be034435 | ||
![]() |
fb8794921e | ||
![]() |
d9febc168e | ||
![]() |
667b97664c | ||
![]() |
098d91f0bb | ||
![]() |
c629355472 | ||
![]() |
8406807552 | ||
![]() |
87186eb568 | ||
![]() |
d899144d43 | ||
![]() |
341f84ca80 | ||
![]() |
797686939f | ||
![]() |
4b1babd4ea | ||
![]() |
7f7c2408c8 | ||
![]() |
98b5bf2694 | ||
![]() |
e73b3b4c9f | ||
![]() |
32793f7872 | ||
![]() |
30335971cf | ||
![]() |
39842c9857 | ||
![]() |
f727389b2b | ||
![]() |
3a4c5a0d0f | ||
![]() |
01ed0c10a0 | ||
![]() |
864599b325 | ||
![]() |
5404dcb93d | ||
![]() |
70a17768a3 | ||
![]() |
229cae771e | ||
![]() |
e3fdcdd601 | ||
![]() |
8831fb9a18 | ||
![]() |
423c2ba7e7 | ||
![]() |
c6c623da78 | ||
![]() |
b0c9176634 | ||
![]() |
8df7f6772c | ||
![]() |
cebaebc356 | ||
![]() |
c0caaa20c3 | ||
![]() |
f2c6a7ddb4 | ||
![]() |
97562c0042 | ||
![]() |
14dedf0101 | ||
![]() |
32a27c14b4 | ||
![]() |
5e491cc0c0 | ||
![]() |
a6bfbad5bd | ||
![]() |
e042233dd9 | ||
![]() |
438bd76c61 | ||
![]() |
3312d97a6b | ||
![]() |
4738961f51 | ||
![]() |
8ff8e7a4bf | ||
![]() |
ba60f885a4 | ||
![]() |
72b4083318 | ||
![]() |
50f5a4e909 | ||
![]() |
347cade55f | ||
![]() |
4858efd06f | ||
![]() |
f157dd419e | ||
![]() |
461818810c | ||
![]() |
3a918a58e5 | ||
![]() |
76dd6d1e20 | ||
![]() |
d731afed90 | ||
![]() |
f523358d08 | ||
![]() |
069624029a | ||
![]() |
646e7c2ef0 | ||
![]() |
af86239f03 | ||
![]() |
8f11bc6270 | ||
![]() |
1ca47334f7 | ||
![]() |
90aec12e84 | ||
![]() |
57e535af52 | ||
![]() |
fd8fcb11f8 | ||
![]() |
51bf63a32a | ||
![]() |
33bbb32d07 | ||
![]() |
cd48caeaa1 | ||
![]() |
ff9ee2f5a9 | ||
![]() |
ea918f3674 | ||
![]() |
6245e81f42 | ||
![]() |
8808e8dfa2 | ||
![]() |
e91079d493 | ||
![]() |
d893bb76cf | ||
![]() |
eba4418672 | ||
![]() |
ec73bbcfa0 | ||
![]() |
4b46120ec1 | ||
![]() |
71ab3a41ed | ||
![]() |
cce8d12bf7 | ||
![]() |
6ccf61e1f2 | ||
![]() |
4e736a9e96 | ||
![]() |
9e040d6946 | ||
![]() |
8ed4199245 | ||
![]() |
1916a0bbf6 | ||
![]() |
0558761482 | ||
![]() |
7fbcca6ed1 | ||
![]() |
c6824f8649 | ||
![]() |
0baaed6cdf | ||
![]() |
e228b77c14 | ||
![]() |
db44d0b6ee | ||
![]() |
45a5d090d9 | ||
![]() |
342bbe5f0b | ||
![]() |
b0a1a4e9d8 | ||
![]() |
daaf134b07 | ||
![]() |
4351805f7c | ||
![]() |
47663fbe35 | ||
![]() |
f3077599bc | ||
![]() |
8acbafcb05 | ||
![]() |
377f641dd4 | ||
![]() |
54c70e9311 | ||
![]() |
a20735426d | ||
![]() |
3f57c66510 | ||
![]() |
ae171dbced | ||
![]() |
dfbb4dde6c | ||
![]() |
9baddf78f6 | ||
![]() |
09195a9b5d | ||
![]() |
3e4e9b298f | ||
![]() |
7e5a0a9bea | ||
![]() |
6e314e07a1 | ||
![]() |
762266cd87 | ||
![]() |
8d99ad3964 | ||
![]() |
3b602ed93a | ||
![]() |
5c3353b3de | ||
![]() |
272108a213 | ||
![]() |
52498c26c8 | ||
![]() |
7acd8ea64c | ||
![]() |
7af423173c | ||
![]() |
36c9772e0e | ||
![]() |
c300d0adb1 | ||
![]() |
48d08f5b28 | ||
![]() |
d6e89c7338 | ||
![]() |
0bbe4b2e5a | ||
![]() |
77dd468c20 | ||
![]() |
0d42173034 | ||
![]() |
2640d4a566 | ||
![]() |
f1e1d55d8c | ||
![]() |
4898b15dea | ||
![]() |
e1a2bccf53 | ||
![]() |
207f9d1fc4 | ||
![]() |
2fe874e56d | ||
![]() |
47db5c1236 | ||
![]() |
ad096f82bf | ||
![]() |
f79e2c8333 | ||
![]() |
6fa75eb905 | ||
![]() |
8ae5e1dc5d | ||
![]() |
6cb1528495 | ||
![]() |
793d900ba5 | ||
![]() |
25c401f2a7 | ||
![]() |
54a4f7a75b | ||
![]() |
50c6b8a831 | ||
![]() |
604471bfe9 | ||
![]() |
53214d4222 | ||
![]() |
25027e0155 | ||
![]() |
958d889cbe | ||
![]() |
9c377b74c1 | ||
![]() |
6884338a34 | ||
![]() |
04c1497673 | ||
![]() |
037cc3b7a4 | ||
![]() |
2365d8c199 | ||
![]() |
84deec4e5a | ||
![]() |
6c69266c0a | ||
![]() |
2d12ef6b78 | ||
![]() |
1072f836ae | ||
![]() |
741b65d0eb | ||
![]() |
e5a4a8606f | ||
![]() |
71c2dc7d2d | ||
![]() |
6ee70550c4 | ||
![]() |
3d0a9017a4 | ||
![]() |
6fba73c66a | ||
![]() |
d34d15242e | ||
![]() |
144334ec58 | ||
![]() |
5b7ca476a7 | ||
![]() |
01ab32c029 | ||
![]() |
d4a10c7b41 | ||
![]() |
c8be2e25cf | ||
![]() |
de7cd8900a | ||
![]() |
9332b42edd | ||
![]() |
b735cac588 | ||
![]() |
ab4d1e0986 | ||
![]() |
413c108c28 | ||
![]() |
17ae05a055 | ||
![]() |
ce1d63d92c | ||
![]() |
fdf7a34f8f | ||
![]() |
f03d346a32 | ||
![]() |
58c9f6e76e | ||
![]() |
7bef003c56 | ||
![]() |
f9cc0c3bf4 | ||
![]() |
b9fb636f0b | ||
![]() |
ae34c4b8cc | ||
![]() |
feacd61408 | ||
![]() |
ab9d4d4c9c | ||
![]() |
84bd994345 | ||
![]() |
30e5c95a27 | ||
![]() |
c919960d2b | ||
![]() |
849275c4b8 | ||
![]() |
539e96c62b | ||
![]() |
03bb4c57f9 | ||
![]() |
545f990837 | ||
![]() |
685db9935a | ||
![]() |
da6f332269 | ||
![]() |
76c0e6f84d | ||
![]() |
5c188939d6 | ||
![]() |
bdd91358ef | ||
![]() |
971a5d9de4 | ||
![]() |
4fae817573 | ||
![]() |
98e0ed6a06 | ||
![]() |
68dcacea93 | ||
![]() |
9f1b6d480b | ||
![]() |
f069adaf15 | ||
![]() |
0d3be44f6a | ||
![]() |
2fbb4615f9 | ||
![]() |
386d6f8ffd | ||
![]() |
f2f1178bcd | ||
![]() |
7994390967 | ||
![]() |
23c455d7b9 | ||
![]() |
36f2e52167 | ||
![]() |
3d543b20b5 | ||
![]() |
22d007e8f4 | ||
![]() |
292308d546 | ||
![]() |
4150baefcb | ||
![]() |
ef5a52b29d | ||
![]() |
c9591e250a | ||
![]() |
a69036e005 | ||
![]() |
2ef5db2938 | ||
![]() |
bf789a2635 | ||
![]() |
1b5fb1ef9e | ||
![]() |
e9c1c0f9c8 | ||
![]() |
39d1ba7fe0 | ||
![]() |
c0ccb57100 | ||
![]() |
1f23b78de2 | ||
![]() |
30ebad91b7 | ||
![]() |
19ee929d65 | ||
![]() |
cffa9c1a28 | ||
![]() |
47af13c8a8 | ||
![]() |
f79aac8d01 | ||
![]() |
a8a61db23e | ||
![]() |
003fa536df | ||
![]() |
db78629e5c | ||
![]() |
62119de408 | ||
![]() |
8f29870334 | ||
![]() |
a343c010c1 | ||
![]() |
356212ecde | ||
![]() |
182e9deada | ||
![]() |
dc157392ae | ||
![]() |
84413c991d | ||
![]() |
fe623d2297 | ||
![]() |
0e6318ea0c | ||
![]() |
5f913738a8 | ||
![]() |
6d5354d7fe | ||
![]() |
2ce82fabab | ||
![]() |
eb1b939f5a | ||
![]() |
a8650b3f0b | ||
![]() |
0db9d88e6f | ||
![]() |
3e11fffaf0 | ||
![]() |
2d57e347aa | ||
![]() |
61d300de6a | ||
![]() |
6140201326 | ||
![]() |
813946a693 | ||
![]() |
d6e763bc57 | ||
![]() |
e9e59bbcc9 | ||
![]() |
70185ad8a1 | ||
![]() |
65c2adbed0 | ||
![]() |
d6f8645e8c | ||
![]() |
e1f880a62b | ||
![]() |
45facc0f78 | ||
![]() |
aaeac2cd72 | ||
![]() |
49cc7890e4 | ||
![]() |
47275850fe | ||
![]() |
823069d13e | ||
![]() |
e3485f01da | ||
![]() |
d511a55466 | ||
![]() |
9f5387384e | ||
![]() |
6292e79253 | ||
![]() |
3ac1b48d10 | ||
![]() |
70fd6cacbc | ||
![]() |
8171ffafc5 | ||
![]() |
f89d70f9a8 | ||
![]() |
8b0aae34ed | ||
![]() |
18b8760bbb | ||
![]() |
46cc91fac8 | ||
![]() |
75d3974e11 | ||
![]() |
301adbc858 | ||
![]() |
18aa1b4e3b | ||
![]() |
2262d03a21 | ||
![]() |
e64e69f539 | ||
![]() |
20c6989074 | ||
![]() |
397d7c18b7 | ||
![]() |
ee4c8b5fe2 | ||
![]() |
6a8844b673 | ||
![]() |
efc1df2c52 | ||
![]() |
0ce29d6efb | ||
![]() |
859527b890 | ||
![]() |
48c0aaf940 | ||
![]() |
7a48061d31 | ||
![]() |
d95df5b486 | ||
![]() |
bce3d4df78 | ||
![]() |
abbac67954 | ||
![]() |
9c9ffd8a08 | ||
![]() |
7bc9a6673a | ||
![]() |
d488791474 | ||
![]() |
fce3adc810 | ||
![]() |
0193d00735 | ||
![]() |
14ba6013ac | ||
![]() |
19e962876d | ||
![]() |
a1e42ff003 | ||
![]() |
c9f35a2d68 | ||
![]() |
2890661d5c | ||
![]() |
2d620952b5 | ||
![]() |
ae4581d02a | ||
![]() |
6daf2bef45 | ||
![]() |
659f15d010 | ||
![]() |
c1360fde7c | ||
![]() |
7cb146765c | ||
![]() |
27ea8211f3 | ||
![]() |
053b84d828 | ||
![]() |
8417267075 | ||
![]() |
c6506d9a2e | ||
![]() |
b253d860ff | ||
![]() |
8e7f1d5e89 | ||
![]() |
df727d64c3 | ||
![]() |
e68d2e88e0 | ||
![]() |
79e4b76551 | ||
![]() |
0c40841d9f | ||
![]() |
c0cbe51828 | ||
![]() |
d434a2ba86 | ||
![]() |
9b82969e44 | ||
![]() |
bb8bdd04af | ||
![]() |
362521dd1e | ||
![]() |
4a8163b3f3 | ||
![]() |
8fa2e9ec96 | ||
![]() |
555af767bd | ||
![]() |
c25ee1d786 | ||
![]() |
d238e509c1 | ||
![]() |
b0a47b6c6d | ||
![]() |
10e1c5b320 | ||
![]() |
90b16e4c8e | ||
![]() |
a323a5c915 | ||
![]() |
f2d6c73f75 | ||
![]() |
6737cfa38d | ||
![]() |
db45323b93 | ||
![]() |
e5c97f1963 | ||
![]() |
6d1a258e7b | ||
![]() |
e5cdc768b3 | ||
![]() |
303e85a72b | ||
![]() |
8bda1d5b92 | ||
![]() |
b2b398ba0d | ||
![]() |
11de9a366c | ||
![]() |
4cd624e6a5 | ||
![]() |
9d4eeeea90 | ||
![]() |
09bbbfbb28 | ||
![]() |
000017ecde | ||
![]() |
31e8a908ee | ||
![]() |
a92a37bc3c | ||
![]() |
b165e71ba9 | ||
![]() |
62cfb68c1b | ||
![]() |
650d285105 | ||
![]() |
fc0c46cb2f | ||
![]() |
15f8e07257 | ||
![]() |
e99352df3e | ||
![]() |
f9536cce52 | ||
![]() |
cacf2dbd73 | ||
![]() |
1063dabf33 | ||
![]() |
52254b5695 | ||
![]() |
27792199f6 | ||
![]() |
629579b840 | ||
![]() |
6404e3047d | ||
![]() |
f8a1e7c43b | ||
![]() |
e6a2caa088 | ||
![]() |
f1c3ddb7c2 | ||
![]() |
71a0b48a68 | ||
![]() |
0490a4bab2 | ||
![]() |
f42ccd67f7 | ||
![]() |
a8724ff7a0 | ||
![]() |
0fc58be5fa | ||
![]() |
d8840bfe7f | ||
![]() |
ea96947df1 | ||
![]() |
5f11200f67 | ||
![]() |
fd89836e9f | ||
![]() |
a038f451d4 | ||
![]() |
f1893fa03a | ||
![]() |
97cdc290c3 | ||
![]() |
3433b8ba90 | ||
![]() |
524d743d6c | ||
![]() |
db9dba4b7a | ||
![]() |
53dc5fbafe | ||
![]() |
576134abde | ||
![]() |
751d9ad7a0 | ||
![]() |
cee0276eef | ||
![]() |
c898e3f323 | ||
![]() |
5a447aa349 | ||
![]() |
13e3a941f4 | ||
![]() |
ad8026038c | ||
![]() |
66447f959f | ||
![]() |
bd967ce1df | ||
![]() |
e422cb9485 | ||
![]() |
f39ca200b9 | ||
![]() |
9ed3861c70 | ||
![]() |
e72a3cbc28 | ||
![]() |
a08d1b18dc | ||
![]() |
5a24d23930 | ||
![]() |
ff656c8029 | ||
![]() |
f0716cdd36 | ||
![]() |
6a6ef776d5 | ||
![]() |
e83963eefc | ||
![]() |
174d62e4cc | ||
![]() |
527f56599f | ||
![]() |
630647e8e7 | ||
![]() |
8112f3820c | ||
![]() |
2dcd1afcc5 | ||
![]() |
6ba4b0141a | ||
![]() |
605a0394e5 | ||
![]() |
09fdf16471 | ||
![]() |
ab5a539c62 | ||
![]() |
3e0b447bc1 | ||
![]() |
d42ecb46ab | ||
![]() |
c0af22b405 | ||
![]() |
2e534b4e17 | ||
![]() |
1363045db2 | ||
![]() |
dfea9dacab | ||
![]() |
932f395932 | ||
![]() |
5ad9ac439c | ||
![]() |
7493936bad | ||
![]() |
ab4d8fe168 | ||
![]() |
f856ddce87 | ||
![]() |
104ae341b1 | ||
![]() |
c4e0294d36 | ||
![]() |
c276fa00ae | ||
![]() |
c68d252508 | ||
![]() |
e2478d14f1 | ||
![]() |
b68f758fd5 | ||
![]() |
5ce7a785d1 | ||
![]() |
ffa28b1a58 | ||
![]() |
511fe60a9c | ||
![]() |
40b781eb0a | ||
![]() |
f676eca2b8 | ||
![]() |
81aab71ffd | ||
![]() |
b3edd2b6c6 | ||
![]() |
63a9e99375 | ||
![]() |
d1735f549c | ||
![]() |
172aeb59d1 | ||
![]() |
f267350a46 | ||
![]() |
bde4da8aab | ||
![]() |
b308f8e9b4 | ||
![]() |
a6ec075f78 | ||
![]() |
85f4966d49 | ||
![]() |
b1ec6dda35 | ||
![]() |
10faeace77 | ||
![]() |
7cc94b0a2d | ||
![]() |
b24b6f0f95 | ||
![]() |
cc0fcc2a18 | ||
![]() |
b9a56afe4c | ||
![]() |
8db1f2c9bc | ||
![]() |
aae03bdf6e | ||
![]() |
a82952f9df | ||
![]() |
0a169fa96f | ||
![]() |
bb1072a3a4 | ||
![]() |
d19c5e236f | ||
![]() |
f6cbcc51d8 | ||
![]() |
1f8a710f0d | ||
![]() |
480ff65bef | ||
![]() |
d97555a380 | ||
![]() |
9e1ac94ebf | ||
![]() |
892eaabdf7 | ||
![]() |
cc60acd4ea | ||
![]() |
652b5aaab5 | ||
![]() |
faff4f1d7e | ||
![]() |
ce0d5fd383 | ||
![]() |
622d7bca79 | ||
![]() |
a623faf260 | ||
![]() |
6f0b839354 | ||
![]() |
8096cd2408 | ||
![]() |
76097d17c4 | ||
![]() |
a21db20631 | ||
![]() |
967c3e597c | ||
![]() |
68867d63b4 | ||
![]() |
be90a3b2bb | ||
![]() |
0bec88cb2b | ||
![]() |
044f52f937 | ||
![]() |
a51126fb06 | ||
![]() |
da411733b6 | ||
![]() |
9357f51fcf | ||
![]() |
078a3b8d05 | ||
![]() |
065464f722 | ||
![]() |
d0ca742a93 | ||
![]() |
3d7254b419 | ||
![]() |
6672372828 | ||
![]() |
3d0f5188ae | ||
![]() |
40603e0561 | ||
![]() |
b7d37b434a | ||
![]() |
06d87f4590 | ||
![]() |
5fe4ab3c8d | ||
![]() |
fe7ce48e48 | ||
![]() |
ef72402df6 | ||
![]() |
794f4782c4 | ||
![]() |
8cd6a0b2f1 | ||
![]() |
22b126fa94 | ||
![]() |
592e484098 | ||
![]() |
7dffcbf645 | ||
![]() |
0769998dd7 | ||
![]() |
0bd8729cc1 | ||
![]() |
ed27af11f8 | ||
![]() |
0e12f9226f | ||
![]() |
b4f624d8f6 | ||
![]() |
a773a63c2a | ||
![]() |
6d6477401e | ||
![]() |
3e478c42f7 | ||
![]() |
1a6bd670d4 | ||
![]() |
651fad7401 | ||
![]() |
558b529caf | ||
![]() |
b0c309e993 | ||
![]() |
838ae23b52 | ||
![]() |
2b1df12052 | ||
![]() |
46ae397fb9 | ||
![]() |
c2b876372b | ||
![]() |
77831b60bf | ||
![]() |
69021ca4b0 | ||
![]() |
fb45c3c144 | ||
![]() |
f72c86e7fe | ||
![]() |
3bbde49781 | ||
![]() |
56f6f77ba2 | ||
![]() |
ac8d8d6edc | ||
![]() |
583923a4d0 | ||
![]() |
4f25b0de91 | ||
![]() |
a92a1f5a24 | ||
![]() |
0a46bdc2ac | ||
![]() |
16e65edd0d | ||
![]() |
eefa0a792a | ||
![]() |
21f930f73b | ||
![]() |
8facb017ed | ||
![]() |
bc310ddd1e | ||
![]() |
5df8cf7ce4 | ||
![]() |
44e0428496 | ||
![]() |
71112d2fdc | ||
![]() |
6e39885fde | ||
![]() |
58e34617bc | ||
![]() |
2e630e50dc | ||
![]() |
1fca37af61 | ||
![]() |
6de1817ef5 | ||
![]() |
73aea01f37 | ||
![]() |
e576ffd63c | ||
![]() |
485bccebef | ||
![]() |
9e745ed7ae | ||
![]() |
b27b2808f2 | ||
![]() |
7e42b1f3eb | ||
![]() |
67ca6a74b2 | ||
![]() |
2304e6d32b | ||
![]() |
598ccab8b4 | ||
![]() |
a5f38a8663 | ||
![]() |
3e0428d450 | ||
![]() |
d825fbe44c | ||
![]() |
adecf0d5fe | ||
![]() |
623d88bee0 | ||
![]() |
64fffb9d4d | ||
![]() |
0253130c36 | ||
![]() |
4964d6414b | ||
![]() |
0b9b7da0e9 | ||
![]() |
17ea83937e | ||
![]() |
f59c3cd1e1 | ||
![]() |
24f2388aa2 | ||
![]() |
99f77b2205 | ||
![]() |
fb36fff63d | ||
![]() |
8e74d3c58c | ||
![]() |
87e7e3017a | ||
![]() |
a1d45aa264 | ||
![]() |
8a8142414a | ||
![]() |
931384c91d | ||
![]() |
53955963ed | ||
![]() |
b3b94962b1 | ||
![]() |
cf7ba7fb44 | ||
![]() |
4db676b4ee | ||
![]() |
ab36c80a26 | ||
![]() |
27076c50cc | ||
![]() |
087b612e16 | ||
![]() |
d84d4dd093 | ||
![]() |
424fd5b591 | ||
![]() |
34fa1e12e7 | ||
![]() |
b617266c6d | ||
![]() |
3874cca29b | ||
![]() |
dd18e1f91e | ||
![]() |
f50aca2809 | ||
![]() |
5c63188200 | ||
![]() |
ade1b7f2bc | ||
![]() |
ca0a46b5bb | ||
![]() |
dd15ade2b9 | ||
![]() |
9cc6f2a9d5 | ||
![]() |
97b39ef98f | ||
![]() |
d6e94d2586 | ||
![]() |
36acad4d1b | ||
![]() |
2dee905361 | ||
![]() |
79164f90d7 | ||
![]() |
2be894c18a | ||
![]() |
bd855044af | ||
![]() |
b36bbdbd90 | ||
![]() |
38b83d7edf | ||
![]() |
9dba1ccd8f | ||
![]() |
e588b0748a | ||
![]() |
1c10059c0a | ||
![]() |
468278d621 | ||
![]() |
11213d85fe | ||
![]() |
c89002f1b8 | ||
![]() |
6b45dab5ad | ||
![]() |
bf26ef58a2 | ||
![]() |
e038d5241e | ||
![]() |
629435e87a | ||
![]() |
1d4737ac69 | ||
![]() |
b0419a86f2 | ||
![]() |
da004d367b | ||
![]() |
6c6ed06677 | ||
![]() |
571454bd71 | ||
![]() |
ec0d3c03f7 | ||
![]() |
39a499b25a | ||
![]() |
daaff1d9c6 | ||
![]() |
73b17dd694 | ||
![]() |
eb693e41dc | ||
![]() |
5f9790d762 | ||
![]() |
d93299d47d | ||
![]() |
19dc975e3a | ||
![]() |
1253d36dd3 | ||
![]() |
ad634c7e88 | ||
![]() |
6e8cfb769f | ||
![]() |
7977bc04cd | ||
![]() |
2cf78229c0 | ||
![]() |
c5e1c63959 | ||
![]() |
52eb08ae96 | ||
![]() |
e5fd2a462c | ||
![]() |
ac84c582b1 | ||
![]() |
4324c4a089 | ||
![]() |
da9de7fd9c | ||
![]() |
eec87af5ff | ||
![]() |
8a90ffa3fb | ||
![]() |
edb4e3d537 | ||
![]() |
805c4f8321 | ||
![]() |
61c7c728ad | ||
![]() |
76c7c3e28e | ||
![]() |
fe2ff49c72 | ||
![]() |
66fee7a794 | ||
![]() |
54ec174331 | ||
![]() |
53d84efb3a | ||
![]() |
a6deee4961 | ||
![]() |
1b80f172d7 | ||
![]() |
77cfdb391c | ||
![]() |
4d8a6c5c9d | ||
![]() |
7f675ac006 | ||
![]() |
5170f28a4f | ||
![]() |
6345ec3b04 | ||
![]() |
fc84bdf68b | ||
![]() |
2939282a7f | ||
![]() |
aa782b2737 | ||
![]() |
8b0b923063 | ||
![]() |
595c60327e | ||
![]() |
cb72799fff | ||
![]() |
a50d76f4ea | ||
![]() |
5821dc7273 | ||
![]() |
7f48c67512 | ||
![]() |
4563273396 | ||
![]() |
5462fcd128 | ||
![]() |
59a1cc2c7a | ||
![]() |
455fbd8cc3 | ||
![]() |
b257625f07 | ||
![]() |
252533b2fd | ||
![]() |
a3d8caf87b | ||
![]() |
122d89a831 | ||
![]() |
65dc6bf940 | ||
![]() |
d5478c11ea | ||
![]() |
a67560c26b | ||
![]() |
bec73ddfae | ||
![]() |
859901ac0c | ||
![]() |
776647d62a | ||
![]() |
83bed793f5 | ||
![]() |
72f03b39c6 | ||
![]() |
ce8781e79f | ||
![]() |
d6281424f4 | ||
![]() |
5e58370b8c | ||
![]() |
637185025f | ||
![]() |
a0a164c4d0 | ||
![]() |
bd15e3cc5c | ||
![]() |
c6a186d436 | ||
![]() |
9659294459 | ||
![]() |
31e7f113f8 | ||
![]() |
9352b51d28 | ||
![]() |
db8d7a7af0 | ||
![]() |
9cfec57c1d | ||
![]() |
1e17fa7057 | ||
![]() |
dfcc805549 | ||
![]() |
d95981cb0c | ||
![]() |
29be4f66d4 | ||
![]() |
bc1e3dacda | ||
![]() |
c9a6724790 | ||
![]() |
57d12dfb17 | ||
![]() |
7fb5d1f0f7 | ||
![]() |
22d3e4bc13 | ||
![]() |
80b3ad255d | ||
![]() |
551dad7c2c | ||
![]() |
bd0aa92c4f | ||
![]() |
f0e989be0c | ||
![]() |
40af0fa84f | ||
![]() |
c27dc70c80 | ||
![]() |
562a4e4ce6 | ||
![]() |
7fa8c01e31 | ||
![]() |
a827f8835b | ||
![]() |
09ad023161 | ||
![]() |
25123e310b | ||
![]() |
9818912cb7 | ||
![]() |
811e935ced | ||
![]() |
745695650c | ||
![]() |
8e2ad427c8 | ||
![]() |
f57704b844 | ||
![]() |
571ece201f | ||
![]() |
b00483653e | ||
![]() |
7af96a49f6 | ||
![]() |
caa446f933 | ||
![]() |
1c7e4ddcd2 | ||
![]() |
21464f200e | ||
![]() |
684731638a | ||
![]() |
eea27a36a4 | ||
![]() |
053831b48c | ||
![]() |
d4fa37b75d | ||
![]() |
850bbceab6 | ||
![]() |
d37cbf8edb | ||
![]() |
e2d63a778b | ||
![]() |
ccb639bcea | ||
![]() |
bf2ad1952c | ||
![]() |
2402f9ea99 | ||
![]() |
1033e73844 | ||
![]() |
ba7d11d854 | ||
![]() |
0eaf02b4fa | ||
![]() |
6f80ec0820 | ||
![]() |
81395a9b9e | ||
![]() |
51e41c99d8 | ||
![]() |
1547325073 | ||
![]() |
d0df44b190 | ||
![]() |
9745c045ba | ||
![]() |
5bb90dc6cb | ||
![]() |
0e4c87d131 | ||
![]() |
a7517eefcb | ||
![]() |
7b3e39f63f | ||
![]() |
a551a0e9f7 | ||
![]() |
e1149e80b3 | ||
![]() |
c531d55f29 | ||
![]() |
06c315760e | ||
![]() |
ce0fa69402 | ||
![]() |
36187aeecb | ||
![]() |
df3be1bd5e | ||
![]() |
1acb0d1bcd | ||
![]() |
3d5bceffee | ||
![]() |
ea5b521882 | ||
![]() |
bb465adba1 | ||
![]() |
79d80cc266 | ||
![]() |
8f56f39ec8 | ||
![]() |
ae9ba2b775 | ||
![]() |
7444026cc3 | ||
![]() |
a480df8a89 | ||
![]() |
74bee7e30b | ||
![]() |
ef90950cd3 | ||
![]() |
7c613cdceb | ||
![]() |
73f7f73396 | ||
![]() |
43e8467860 | ||
![]() |
3734812557 | ||
![]() |
b290510faf | ||
![]() |
27c2e8b938 | ||
![]() |
25f4e6cf67 | ||
![]() |
4445d918be | ||
![]() |
9d62c620fd | ||
![]() |
1b42fb7a89 | ||
![]() |
61e18e7266 | ||
![]() |
c9ed5f6a79 | ||
![]() |
9cafe8735d | ||
![]() |
1404532cca | ||
![]() |
52bb22a794 | ||
![]() |
597551859e | ||
![]() |
f91f423ae4 | ||
![]() |
c5af43ee34 | ||
![]() |
a3ce640c7e | ||
![]() |
94d14e3900 | ||
![]() |
862fb9f2ae | ||
![]() |
0329a18875 | ||
![]() |
44b2ca1830 | ||
![]() |
40ed104bce | ||
![]() |
29c5bc8206 | ||
![]() |
db23262611 | ||
![]() |
4bcc7c9602 | ||
![]() |
34c3647244 | ||
![]() |
c0d6a9c5f0 | ||
![]() |
516a7abc2a | ||
![]() |
1407c9062d | ||
![]() |
945a6a8155 | ||
![]() |
5c8daedf72 | ||
![]() |
f822161b14 | ||
![]() |
6c83f85d5d | ||
![]() |
5182e9a883 | ||
![]() |
775ab9a8d1 | ||
![]() |
4eef05a980 | ||
![]() |
81663a1531 | ||
![]() |
7a7533f794 | ||
![]() |
fd778822f9 | ||
![]() |
0e607a95ee | ||
![]() |
8293771f58 | ||
![]() |
52edad6f12 | ||
![]() |
29381cf054 | ||
![]() |
d1d65e65ad | ||
![]() |
e56e5442fd | ||
![]() |
14cd8f5479 | ||
![]() |
e3b93f0a22 | ||
![]() |
30eb5416f3 | ||
![]() |
ed8fc2747a | ||
![]() |
4bc5a0f1d9 | ||
![]() |
792c9774a5 | ||
![]() |
4bbde479a5 | ||
![]() |
5ec52e2e5b | ||
![]() |
aa0fe5aeb3 | ||
![]() |
19e6ba98f0 | ||
![]() |
13ecddaef1 | ||
![]() |
60cf8885b0 | ||
![]() |
9a142dd5ec | ||
![]() |
b12236576b | ||
![]() |
4a10779064 | ||
![]() |
91d011e1f2 | ||
![]() |
74a475322e | ||
![]() |
1b2385dfce | ||
![]() |
2646fefce4 | ||
![]() |
5791563ef0 | ||
![]() |
7f35ec9b6a | ||
![]() |
5c3d9117c5 | ||
![]() |
92a23b7e9d | ||
![]() |
11cb7dc24c | ||
![]() |
d74981775c | ||
![]() |
c872d97295 | ||
![]() |
1c5f5950fa | ||
![]() |
c4b23b4d39 | ||
![]() |
b2e7477467 | ||
![]() |
30860ae9f9 | ||
![]() |
d77ca18b0b | ||
![]() |
388f16169e | ||
![]() |
a0ea00b692 | ||
![]() |
e75e9c4818 | ||
![]() |
6f3c371435 | ||
![]() |
2486f893eb | ||
![]() |
24b27e3863 | ||
![]() |
2d87fe036d | ||
![]() |
200e7a75f0 | ||
![]() |
d0b5c2f307 | ||
![]() |
98bf2ac055 | ||
![]() |
baed6cc291 | ||
![]() |
4589a192f6 | ||
![]() |
902ae750dc | ||
![]() |
f2ec640b06 | ||
![]() |
ca6b5ae4ca | ||
![]() |
0811e46b6c | ||
![]() |
c37e6a17fb | ||
![]() |
18739d122e | ||
![]() |
96ff76dc43 | ||
![]() |
620969b606 | ||
![]() |
270796ffe8 | ||
![]() |
c00835692b | ||
![]() |
b5cd5bd8fa | ||
![]() |
ab6ee04022 | ||
![]() |
4fd0c41665 | ||
![]() |
508ed8ad1d | ||
![]() |
a443713b55 | ||
![]() |
9fdbd5cfb5 | ||
![]() |
6c4fac68ca | ||
![]() |
04dbb7d2f2 | ||
![]() |
c4edd2fffa | ||
![]() |
c7eafd69c2 | ||
![]() |
64460681b8 | ||
![]() |
1e16de13bc | ||
![]() |
c606f04cce | ||
![]() |
332e2a38c9 | ||
![]() |
94d78e68cd | ||
![]() |
42eea0017d | ||
![]() |
db3c69fbcd | ||
![]() |
87f714f7c3 | ||
![]() |
31154bb9f5 | ||
![]() |
efa376f4f4 | ||
![]() |
af7e388c41 | ||
![]() |
a2ea76f19f | ||
![]() |
28f12eeec2 | ||
![]() |
30b5b74f79 | ||
![]() |
26f3f3e2e2 | ||
![]() |
8bc374c916 | ||
![]() |
f84b205b4b | ||
![]() |
2ae03cc993 | ||
![]() |
b2d991d74a | ||
![]() |
2c7530a6cf | ||
![]() |
1b2f5af1c0 | ||
![]() |
b5015b82c2 | ||
![]() |
620f31d6a6 | ||
![]() |
3b354462a7 | ||
![]() |
978ef6bd92 | ||
![]() |
4f9a056bea | ||
![]() |
febc4b6438 | ||
![]() |
e782ba2fee | ||
![]() |
317aedd929 | ||
![]() |
f1859afc4b | ||
![]() |
69e63f2c74 | ||
![]() |
be43bd2d20 | ||
![]() |
0e41f8cf4c | ||
![]() |
2f762838e7 | ||
![]() |
ad745e5dc8 | ||
![]() |
72b1954939 | ||
![]() |
518920c129 | ||
![]() |
ad9c0446e2 | ||
![]() |
2bfb55a305 | ||
![]() |
9490251251 | ||
![]() |
c6c62088cc | ||
![]() |
fac23dbbdc | ||
![]() |
a238b8eee4 | ||
![]() |
17b66e495f | ||
![]() |
76f8680db0 | ||
![]() |
43965828c8 | ||
![]() |
75aa3764ea | ||
![]() |
a753d3c1d9 | ||
![]() |
d2e6608f01 |
3712 changed files with 189915 additions and 255810 deletions
|
@ -1,10 +1,19 @@
|
|||
---
|
||||
name: Bug report
|
||||
about: Report a bug
|
||||
title: ''
|
||||
labels: type:bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
<!--
|
||||
|
||||
Note: If you are using www.overleaf.com and have a problem,
|
||||
Note: If you are using www.overleaf.com and have a problem,
|
||||
or if you would like to request a new feature please contact
|
||||
the support team at support@overleaf.com
|
||||
|
||||
This form should only be used to report bugs in the
|
||||
|
||||
This form should only be used to report bugs in the
|
||||
Community Edition release of Overleaf.
|
||||
|
||||
-->
|
|
@ -14,6 +14,8 @@ When submitting an issue please describe the issue as clearly as possible, inclu
|
|||
reproduce the bug, which situations it appears in, what you expected to happen, and what actually happens.
|
||||
If you can include a screenshot for front end issues that is very helpful.
|
||||
|
||||
**Note**: If you are using [www.overleaf.com](www.overleaf.com) and have a problem, or if you would like to request a new feature, please contact the Support team at support@overleaf.com. Raise an issue here only to report bugs in the Community Edition release of Overleaf.
|
||||
|
||||
Pull Requests
|
||||
-------------
|
||||
|
||||
|
@ -34,7 +36,7 @@ Please see [our security policy](https://github.com/overleaf/overleaf/security/p
|
|||
Contributor License Agreement
|
||||
-----------------------------
|
||||
|
||||
Before we can accept any contributions of code, we need you to agree to our
|
||||
Before we can accept any contributions of code, we need you to agree to our
|
||||
[Contributor License Agreement](https://docs.google.com/forms/d/e/1FAIpQLSef79XH3mb7yIiMzZw-yALEegS-wyFetvjTiNBfZvf_IHD2KA/viewform?usp=sf_link).
|
||||
This is to ensure that you own the copyright of your contribution, and that you
|
||||
agree to give us a license to use it in both the open source version, and the version
|
||||
|
|
65
README.md
65
README.md
|
@ -14,39 +14,52 @@
|
|||
<a href="#license">License</a>
|
||||
</p>
|
||||
|
||||
<img src="doc/screenshot.png" alt="A screenshot of a project being edited in Overleaf Community Edition">
|
||||
<img src="doc/screenshot.png" alt="A screenshot of a project being edited in Overleaf Extended Community Edition">
|
||||
<p align="center">
|
||||
Figure 1: A screenshot of a project being edited in Overleaf Community Edition.
|
||||
Figure 1: A screenshot of a project being edited in Overleaf Extended Community Edition.
|
||||
</p>
|
||||
|
||||
## Community Edition
|
||||
|
||||
[Overleaf](https://www.overleaf.com) is an open-source online real-time collaborative LaTeX editor. We run a hosted version at [www.overleaf.com](https://www.overleaf.com), but you can also run your own local version, and contribute to the development of Overleaf.
|
||||
[Overleaf](https://www.overleaf.com) is an open-source online real-time collaborative LaTeX editor. Overleaf runs a hosted version at [www.overleaf.com](https://www.overleaf.com), but you can also run your own local version, and contribute to the development of Overleaf.
|
||||
|
||||
## Extended Community Edition
|
||||
|
||||
The present "extended" version of Overleaf CE includes:
|
||||
|
||||
- Template Gallery
|
||||
- Sandboxed Compiles with TeX Live image selection
|
||||
- LDAP authentication
|
||||
- SAML authentication
|
||||
- OpenID Connect authentication
|
||||
- Real-time track changes and comments
|
||||
- Autocomplete of reference keys
|
||||
- Symbol Palette
|
||||
- "From External URL" feature
|
||||
|
||||
> [!CAUTION]
|
||||
> Overleaf Community Edition is intended for use in environments where **all** users are trusted. Community Edition is **not** appropriate for scenarios where isolation of users is required due to Sandbox Compiles not being available. When not using Sandboxed Compiles, users have full read and write access to the `sharelatex` container resources (filesystem, network, environment variables) when running LaTeX compiles.
|
||||
Therefore, in any environment where not all users can be fully trusted, it is strongly recommended to enable the Sandboxed Compiles feature available in the Extended Community Edition.
|
||||
|
||||
For more information on Sandbox Compiles check out Overleaf [documentation](https://docs.overleaf.com/on-premises/configuration/overleaf-toolkit/server-pro-only-configuration/sandboxed-compiles).
|
||||
|
||||
## Enterprise
|
||||
|
||||
If you want help installing and maintaining Overleaf in your lab or workplace, we offer an officially supported version called [Overleaf Server Pro](https://www.overleaf.com/for/enterprises). It also includes more features for security (SSO with LDAP or SAML), administration and collaboration (e.g. tracked changes). [Find out more!](https://www.overleaf.com/for/enterprises)
|
||||
|
||||
## Keeping up to date
|
||||
|
||||
Sign up to the [mailing list](https://mailchi.mp/overleaf.com/community-edition-and-server-pro) to get updates on Overleaf releases and development.
|
||||
If you want help installing and maintaining Overleaf in your lab or workplace, Overleaf offers an officially supported version called [Overleaf Server Pro](https://www.overleaf.com/for/enterprises).
|
||||
|
||||
## Installation
|
||||
|
||||
We have detailed installation instructions in the [Overleaf Toolkit](https://github.com/overleaf/toolkit/).
|
||||
|
||||
## Upgrading
|
||||
|
||||
If you are upgrading from a previous version of Overleaf, please see the [Release Notes section on the Wiki](https://github.com/overleaf/overleaf/wiki#release-notes) for all of the versions between your current version and the version you are upgrading to.
|
||||
Detailed installation instructions can be found in the [Overleaf Toolkit](https://github.com/overleaf/toolkit/).
|
||||
Configuration details and release history for the Extended Community Edition can be found on the [Extended CE Wiki Page](https://github.com/yu-i-i/overleaf-cep/wiki).
|
||||
|
||||
## Overleaf Docker Image
|
||||
|
||||
This repo contains two dockerfiles, [`Dockerfile-base`](server-ce/Dockerfile-base), which builds the
|
||||
`sharelatex/sharelatex-base` image, and [`Dockerfile`](server-ce/Dockerfile) which builds the
|
||||
`sharelatex/sharelatex` (or "community") image.
|
||||
`sharelatex/sharelatex-base:ext-ce` image, and [`Dockerfile`](server-ce/Dockerfile) which builds the
|
||||
`sharelatex/sharelatex:ext-ce` image.
|
||||
|
||||
The Base image generally contains the basic dependencies like `wget` and
|
||||
`aspell`, plus `texlive`. We split this out because it's a pretty heavy set of
|
||||
The Base image generally contains the basic dependencies like `wget`, plus `texlive`.
|
||||
This is split out because it's a pretty heavy set of
|
||||
dependencies, and it's nice to not have to rebuild all of that every time.
|
||||
|
||||
The `sharelatex/sharelatex` image extends the base image and adds the actual Overleaf code
|
||||
|
@ -54,23 +67,19 @@ and services.
|
|||
|
||||
Use `make build-base` and `make build-community` from `server-ce/` to build these images.
|
||||
|
||||
We use the [Phusion base-image](https://github.com/phusion/baseimage-docker)
|
||||
(which is extended by our `base` image) to provide us with a VM-like container
|
||||
The [Phusion base-image](https://github.com/phusion/baseimage-docker)
|
||||
(which is extended by the `base` image) provides a VM-like container
|
||||
in which to run the Overleaf services. Baseimage uses the `runit` service
|
||||
manager to manage services, and we add our init-scripts from the `server-ce/runit`
|
||||
folder.
|
||||
|
||||
|
||||
## Contributing
|
||||
|
||||
Please see the [CONTRIBUTING](CONTRIBUTING.md) file for information on contributing to the development of Overleaf.
|
||||
manager to manage services, and init scripts from the `server-ce/runit`
|
||||
folder are added.
|
||||
|
||||
## Authors
|
||||
|
||||
[The Overleaf Team](https://www.overleaf.com/about)
|
||||
[The Overleaf Team](https://www.overleaf.com/about)
|
||||
[yu-i-i](https://github.com/yu-i-i/overleaf-cep) — Extensions for CE unless otherwise noted
|
||||
|
||||
## License
|
||||
|
||||
The code in this repository is released under the GNU AFFERO GENERAL PUBLIC LICENSE, version 3. A copy can be found in the [`LICENSE`](LICENSE) file.
|
||||
|
||||
Copyright (c) Overleaf, 2014-2024.
|
||||
Copyright (c) Overleaf, 2014-2025.
|
||||
|
|
3
bin/shared/mongodb-init-replica-set.js
Normal file
3
bin/shared/mongodb-init-replica-set.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
/* eslint-disable no-undef */
|
||||
|
||||
rs.initiate({ _id: 'overleaf', members: [{ _id: 0, host: 'mongo:27017' }] })
|
|
@ -11,12 +11,6 @@ bin/build
|
|||
> [!NOTE]
|
||||
> If Docker is running out of RAM while building the services in parallel, create a `.env` file in this directory containing `COMPOSE_PARALLEL_LIMIT=1`.
|
||||
|
||||
Next, initialize the database:
|
||||
|
||||
```shell
|
||||
bin/init
|
||||
```
|
||||
|
||||
Then start the services:
|
||||
|
||||
```shell
|
||||
|
@ -48,7 +42,7 @@ To do this, use the included `bin/dev` script:
|
|||
bin/dev
|
||||
```
|
||||
|
||||
This will start all services using `nodemon`, which will automatically monitor the code and restart the services as necessary.
|
||||
This will start all services using `node --watch`, which will automatically monitor the code and restart the services as necessary.
|
||||
|
||||
To improve performance, you can start only a subset of the services in development mode by providing a space-separated list to the `bin/dev` script:
|
||||
|
||||
|
@ -83,6 +77,7 @@ each service:
|
|||
| `filestore` | 9235 |
|
||||
| `notifications` | 9236 |
|
||||
| `real-time` | 9237 |
|
||||
| `references` | 9238 |
|
||||
| `history-v1` | 9239 |
|
||||
| `project-history` | 9240 |
|
||||
|
||||
|
|
|
@ -1,6 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
docker compose up --detach mongo
|
||||
curl --max-time 10 --retry 5 --retry-delay 5 --retry-all-errors --silent --output /dev/null localhost:27017
|
||||
docker compose exec mongo mongosh --eval "rs.initiate({ _id: 'overleaf', members: [{ _id: 0, host: 'mongo:27017' }] })"
|
||||
docker compose down mongo
|
|
@ -6,14 +6,18 @@ DOCUMENT_UPDATER_HOST=document-updater
|
|||
FILESTORE_HOST=filestore
|
||||
GRACEFUL_SHUTDOWN_DELAY_SECONDS=0
|
||||
HISTORY_V1_HOST=history-v1
|
||||
HISTORY_REDIS_HOST=redis
|
||||
LISTEN_ADDRESS=0.0.0.0
|
||||
MONGO_HOST=mongo
|
||||
MONGO_URL=mongodb://mongo/sharelatex?directConnection=true
|
||||
NOTIFICATIONS_HOST=notifications
|
||||
PROJECT_HISTORY_HOST=project-history
|
||||
QUEUES_REDIS_HOST=redis
|
||||
REALTIME_HOST=real-time
|
||||
REDIS_HOST=redis
|
||||
SPELLING_HOST=spelling
|
||||
REFERENCES_HOST=references
|
||||
SESSION_SECRET=foo
|
||||
V1_HISTORY_HOST=history-v1
|
||||
WEBPACK_HOST=webpack
|
||||
WEB_API_PASSWORD=overleaf
|
||||
WEB_API_USER=overleaf
|
||||
|
|
|
@ -112,8 +112,19 @@ services:
|
|||
- ../services/real-time/app.js:/overleaf/services/real-time/app.js
|
||||
- ../services/real-time/config:/overleaf/services/real-time/config
|
||||
|
||||
references:
|
||||
command: ["node", "--watch", "app.js"]
|
||||
environment:
|
||||
- NODE_OPTIONS=--inspect=0.0.0.0:9229
|
||||
ports:
|
||||
- "127.0.0.1:9238:9229"
|
||||
volumes:
|
||||
- ../services/references/app:/overleaf/services/references/app
|
||||
- ../services/references/config:/overleaf/services/references/config
|
||||
- ../services/references/app.js:/overleaf/services/references/app.js
|
||||
|
||||
web:
|
||||
command: ["node", "--watch", "app.js", "--watch-locales"]
|
||||
command: ["node", "--watch", "app.mjs", "--watch-locales"]
|
||||
environment:
|
||||
- NODE_OPTIONS=--inspect=0.0.0.0:9229
|
||||
ports:
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
volumes:
|
||||
clsi-cache:
|
||||
clsi-output:
|
||||
filestore-public-files:
|
||||
filestore-template-files:
|
||||
filestore-uploads:
|
||||
|
@ -8,7 +7,6 @@ volumes:
|
|||
mongo-data:
|
||||
redis-data:
|
||||
sharelatex-data:
|
||||
spelling-cache:
|
||||
web-data:
|
||||
history-v1-buckets:
|
||||
|
||||
|
@ -27,15 +25,16 @@ services:
|
|||
env_file:
|
||||
- dev.env
|
||||
environment:
|
||||
- DOCKER_RUNNER=true
|
||||
- TEXLIVE_IMAGE=texlive-full # docker build texlive -t texlive-full
|
||||
- COMPILES_HOST_DIR=${PWD}/compiles
|
||||
- SANDBOXED_COMPILES=true
|
||||
- SANDBOXED_COMPILES_HOST_DIR_COMPILES=${PWD}/compiles
|
||||
- SANDBOXED_COMPILES_HOST_DIR_OUTPUT=${PWD}/output
|
||||
user: root
|
||||
volumes:
|
||||
- ${PWD}/compiles:/overleaf/services/clsi/compiles
|
||||
- ${PWD}/output:/overleaf/services/clsi/output
|
||||
- ${DOCKER_SOCKET_PATH:-/var/run/docker.sock}:/var/run/docker.sock
|
||||
- clsi-cache:/overleaf/services/clsi/cache
|
||||
- clsi-output:/overleaf/services/clsi/output
|
||||
|
||||
contacts:
|
||||
build:
|
||||
|
@ -89,12 +88,20 @@ services:
|
|||
- history-v1-buckets:/buckets
|
||||
|
||||
mongo:
|
||||
image: mongo:5
|
||||
image: mongo:6.0
|
||||
command: --replSet overleaf
|
||||
ports:
|
||||
- "127.0.0.1:27017:27017" # for debugging
|
||||
volumes:
|
||||
- mongo-data:/data/db
|
||||
- ../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js
|
||||
environment:
|
||||
MONGO_INITDB_DATABASE: sharelatex
|
||||
extra_hosts:
|
||||
# Required when using the automatic database setup for initializing the
|
||||
# replica set. This override is not needed when running the setup after
|
||||
# starting up mongo.
|
||||
- mongo:127.0.0.1
|
||||
|
||||
notifications:
|
||||
build:
|
||||
|
@ -116,7 +123,7 @@ services:
|
|||
dockerfile: services/real-time/Dockerfile
|
||||
env_file:
|
||||
- dev.env
|
||||
|
||||
|
||||
redis:
|
||||
image: redis:5
|
||||
ports:
|
||||
|
@ -124,14 +131,12 @@ services:
|
|||
volumes:
|
||||
- redis-data:/data
|
||||
|
||||
spelling:
|
||||
references:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: services/spelling/Dockerfile
|
||||
dockerfile: services/references/Dockerfile
|
||||
env_file:
|
||||
- dev.env
|
||||
volumes:
|
||||
- spelling-cache:/overleaf/services/spelling/cache
|
||||
|
||||
web:
|
||||
build:
|
||||
|
@ -142,11 +147,11 @@ services:
|
|||
- dev.env
|
||||
environment:
|
||||
- APP_NAME=Overleaf Community Edition
|
||||
- ENABLED_LINKED_FILE_TYPES=project_file,project_output_file
|
||||
- ENABLED_LINKED_FILE_TYPES=project_file,project_output_file,url
|
||||
- EMAIL_CONFIRMATION_DISABLED=true
|
||||
- NODE_ENV=development
|
||||
- OVERLEAF_ALLOW_PUBLIC_ACCESS=true
|
||||
command: ["node", "app.js"]
|
||||
command: ["node", "app.mjs"]
|
||||
volumes:
|
||||
- sharelatex-data:/var/lib/overleaf
|
||||
- web-data:/overleaf/services/web/data
|
||||
|
@ -163,13 +168,13 @@ services:
|
|||
- notifications
|
||||
- project-history
|
||||
- real-time
|
||||
- spelling
|
||||
- references
|
||||
|
||||
webpack:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: services/web/Dockerfile
|
||||
target: dev
|
||||
target: webpack
|
||||
command: ["npx", "webpack", "serve", "--config", "webpack.config.dev-env.js"]
|
||||
ports:
|
||||
- "127.0.0.1:80:3808"
|
||||
|
|
BIN
doc/logo.png
BIN
doc/logo.png
Binary file not shown.
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 13 KiB |
Binary file not shown.
Before Width: | Height: | Size: 587 KiB After Width: | Height: | Size: 1 MiB |
|
@ -32,7 +32,7 @@ services:
|
|||
OVERLEAF_REDIS_HOST: redis
|
||||
REDIS_HOST: redis
|
||||
|
||||
ENABLED_LINKED_FILE_TYPES: 'project_file,project_output_file'
|
||||
ENABLED_LINKED_FILE_TYPES: 'project_file,project_output_file,url'
|
||||
|
||||
# Enables Thumbnail generation using ImageMagick
|
||||
ENABLE_CONVERSIONS: 'true'
|
||||
|
@ -40,10 +40,6 @@ services:
|
|||
# Disables email confirmation requirement
|
||||
EMAIL_CONFIRMATION_DISABLED: 'true'
|
||||
|
||||
# temporary fix for LuaLaTex compiles
|
||||
# see https://github.com/overleaf/overleaf/issues/695
|
||||
TEXMFVAR: /var/lib/overleaf/tmp/texmf-var
|
||||
|
||||
## Set for SSL via nginx-proxy
|
||||
#VIRTUAL_HOST: 103.112.212.22
|
||||
|
||||
|
@ -77,11 +73,19 @@ services:
|
|||
## Server Pro ##
|
||||
################
|
||||
|
||||
## Sandboxed Compiles: https://github.com/overleaf/overleaf/wiki/Server-Pro:-Sandboxed-Compiles
|
||||
## The Community Edition is intended for use in environments where all users are trusted and is not appropriate for
|
||||
## scenarios where isolation of users is required. Sandboxed Compiles are not available in the Community Edition,
|
||||
## so the following environment variables must be commented out to avoid compile issues.
|
||||
##
|
||||
## Sandboxed Compiles: https://docs.overleaf.com/on-premises/configuration/overleaf-toolkit/server-pro-only-configuration/sandboxed-compiles
|
||||
SANDBOXED_COMPILES: 'true'
|
||||
SANDBOXED_COMPILES_SIBLING_CONTAINERS: 'true'
|
||||
### Bind-mount source for /var/lib/overleaf/data/compiles inside the container.
|
||||
SANDBOXED_COMPILES_HOST_DIR: '/home/user/sharelatex_data/data/compiles'
|
||||
SANDBOXED_COMPILES_HOST_DIR_COMPILES: '/home/user/sharelatex_data/data/compiles'
|
||||
### Bind-mount source for /var/lib/overleaf/data/output inside the container.
|
||||
SANDBOXED_COMPILES_HOST_DIR_OUTPUT: '/home/user/sharelatex_data/data/output'
|
||||
### Backwards compatibility (before Server Pro 5.5)
|
||||
DOCKER_RUNNER: 'true'
|
||||
SANDBOXED_COMPILES_SIBLING_CONTAINERS: 'true'
|
||||
|
||||
## Works with test LDAP server shown at bottom of docker compose
|
||||
# OVERLEAF_LDAP_URL: 'ldap://ldap:389'
|
||||
|
@ -102,12 +106,12 @@ services:
|
|||
|
||||
mongo:
|
||||
restart: always
|
||||
image: mongo:5.0
|
||||
image: mongo:6.0
|
||||
container_name: mongo
|
||||
command: '--replSet overleaf'
|
||||
volumes:
|
||||
- ~/mongo_data:/data/db
|
||||
- ./mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js
|
||||
- ./bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js
|
||||
environment:
|
||||
MONGO_INITDB_DATABASE: sharelatex
|
||||
extra_hosts:
|
||||
|
@ -115,7 +119,7 @@ services:
|
|||
# This override is not needed when running the setup after starting up mongo.
|
||||
- mongo:127.0.0.1
|
||||
healthcheck:
|
||||
test: echo 'db.stats().ok' | mongo localhost:27017/test --quiet
|
||||
test: echo 'db.stats().ok' | mongosh localhost:27017/test --quiet
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
node_modules/
|
46
libraries/access-token-encryptor/.gitignore
vendored
46
libraries/access-token-encryptor/.gitignore
vendored
|
@ -1,46 +0,0 @@
|
|||
compileFolder
|
||||
|
||||
Compiled source #
|
||||
###################
|
||||
*.com
|
||||
*.class
|
||||
*.dll
|
||||
*.exe
|
||||
*.o
|
||||
*.so
|
||||
|
||||
# Packages #
|
||||
############
|
||||
# it's better to unpack these files and commit the raw source
|
||||
# git has its own built in compression methods
|
||||
*.7z
|
||||
*.dmg
|
||||
*.gz
|
||||
*.iso
|
||||
*.jar
|
||||
*.rar
|
||||
*.tar
|
||||
*.zip
|
||||
|
||||
# Logs and databases #
|
||||
######################
|
||||
*.log
|
||||
*.sql
|
||||
*.sqlite
|
||||
|
||||
# OS generated files #
|
||||
######################
|
||||
.DS_Store?
|
||||
ehthumbs.db
|
||||
Icon?
|
||||
Thumbs.db
|
||||
|
||||
/node_modules/*
|
||||
data/*/*
|
||||
|
||||
**.swp
|
||||
|
||||
/log.json
|
||||
hash_folder
|
||||
|
||||
.npmrc
|
|
@ -1 +1 @@
|
|||
18.20.2
|
||||
22.17.0
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
access-token-encryptor
|
||||
--dependencies=None
|
||||
--docker-repos=gcr.io/overleaf-ops
|
||||
--docker-repos=us-east1-docker.pkg.dev/overleaf-ops/ol-docker
|
||||
--env-add=
|
||||
--env-pass-through=
|
||||
--esmock-loader=False
|
||||
--is-library=True
|
||||
--node-version=18.20.2
|
||||
--node-version=22.17.0
|
||||
--public-repo=False
|
||||
--script-version=4.5.0
|
||||
--script-version=4.7.0
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
const { promisify } = require('util')
|
||||
const crypto = require('crypto')
|
||||
const { promisify } = require('node:util')
|
||||
const crypto = require('node:crypto')
|
||||
|
||||
const ALGORITHM = 'aes-256-ctr'
|
||||
|
||||
|
|
|
@ -21,7 +21,7 @@
|
|||
"devDependencies": {
|
||||
"chai": "^4.3.6",
|
||||
"chai-as-promised": "^7.1.1",
|
||||
"mocha": "^10.2.0",
|
||||
"mocha": "^11.1.0",
|
||||
"sandboxed-module": "^2.0.4",
|
||||
"typescript": "^5.0.4"
|
||||
}
|
||||
|
|
|
@ -1,4 +1,13 @@
|
|||
const chai = require('chai')
|
||||
const chaiAsPromised = require('chai-as-promised')
|
||||
const SandboxedModule = require('sandboxed-module')
|
||||
|
||||
chai.use(chaiAsPromised)
|
||||
|
||||
SandboxedModule.configure({
|
||||
sourceTransformers: {
|
||||
removeNodePrefix: function (source) {
|
||||
return source.replace(/require\(['"]node:/g, "require('")
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
node_modules/
|
3
libraries/fetch-utils/.gitignore
vendored
3
libraries/fetch-utils/.gitignore
vendored
|
@ -1,3 +0,0 @@
|
|||
|
||||
# managed by monorepo$ bin/update_build_scripts
|
||||
.npmrc
|
|
@ -1 +1 @@
|
|||
18.20.2
|
||||
22.17.0
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
fetch-utils
|
||||
--dependencies=None
|
||||
--docker-repos=gcr.io/overleaf-ops
|
||||
--docker-repos=us-east1-docker.pkg.dev/overleaf-ops/ol-docker
|
||||
--env-add=
|
||||
--env-pass-through=
|
||||
--esmock-loader=False
|
||||
--is-library=True
|
||||
--node-version=18.20.2
|
||||
--node-version=22.17.0
|
||||
--public-repo=False
|
||||
--script-version=4.5.0
|
||||
--script-version=4.7.0
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
const _ = require('lodash')
|
||||
const { Readable } = require('stream')
|
||||
const { Readable } = require('node:stream')
|
||||
const OError = require('@overleaf/o-error')
|
||||
const fetch = require('node-fetch')
|
||||
const http = require('http')
|
||||
const https = require('https')
|
||||
const http = require('node:http')
|
||||
const https = require('node:https')
|
||||
|
||||
/**
|
||||
* @import { Response } from 'node-fetch'
|
||||
|
@ -23,11 +23,11 @@ async function fetchJson(url, opts = {}) {
|
|||
}
|
||||
|
||||
async function fetchJsonWithResponse(url, opts = {}) {
|
||||
const { fetchOpts } = parseOpts(opts)
|
||||
const { fetchOpts, detachSignal } = parseOpts(opts)
|
||||
fetchOpts.headers = fetchOpts.headers ?? {}
|
||||
fetchOpts.headers.Accept = fetchOpts.headers.Accept ?? 'application/json'
|
||||
|
||||
const response = await performRequest(url, fetchOpts)
|
||||
const response = await performRequest(url, fetchOpts, detachSignal)
|
||||
if (!response.ok) {
|
||||
const body = await maybeGetResponseBody(response)
|
||||
throw new RequestFailedError(url, opts, response, body)
|
||||
|
@ -53,8 +53,8 @@ async function fetchStream(url, opts = {}) {
|
|||
}
|
||||
|
||||
async function fetchStreamWithResponse(url, opts = {}) {
|
||||
const { fetchOpts, abortController } = parseOpts(opts)
|
||||
const response = await performRequest(url, fetchOpts)
|
||||
const { fetchOpts, abortController, detachSignal } = parseOpts(opts)
|
||||
const response = await performRequest(url, fetchOpts, detachSignal)
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await maybeGetResponseBody(response)
|
||||
|
@ -76,8 +76,8 @@ async function fetchStreamWithResponse(url, opts = {}) {
|
|||
* @throws {RequestFailedError} if the response has a failure status code
|
||||
*/
|
||||
async function fetchNothing(url, opts = {}) {
|
||||
const { fetchOpts } = parseOpts(opts)
|
||||
const response = await performRequest(url, fetchOpts)
|
||||
const { fetchOpts, detachSignal } = parseOpts(opts)
|
||||
const response = await performRequest(url, fetchOpts, detachSignal)
|
||||
if (!response.ok) {
|
||||
const body = await maybeGetResponseBody(response)
|
||||
throw new RequestFailedError(url, opts, response, body)
|
||||
|
@ -95,9 +95,22 @@ async function fetchNothing(url, opts = {}) {
|
|||
* @throws {RequestFailedError} if the response has a non redirect status code or missing Location header
|
||||
*/
|
||||
async function fetchRedirect(url, opts = {}) {
|
||||
const { fetchOpts } = parseOpts(opts)
|
||||
const { location } = await fetchRedirectWithResponse(url, opts)
|
||||
return location
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a request and extract the redirect from the response.
|
||||
*
|
||||
* @param {string | URL} url - request URL
|
||||
* @param {object} opts - fetch options
|
||||
* @return {Promise<{location: string, response: Response}>}
|
||||
* @throws {RequestFailedError} if the response has a non redirect status code or missing Location header
|
||||
*/
|
||||
async function fetchRedirectWithResponse(url, opts = {}) {
|
||||
const { fetchOpts, detachSignal } = parseOpts(opts)
|
||||
fetchOpts.redirect = 'manual'
|
||||
const response = await performRequest(url, fetchOpts)
|
||||
const response = await performRequest(url, fetchOpts, detachSignal)
|
||||
if (response.status < 300 || response.status >= 400) {
|
||||
const body = await maybeGetResponseBody(response)
|
||||
throw new RequestFailedError(url, opts, response, body)
|
||||
|
@ -112,7 +125,7 @@ async function fetchRedirect(url, opts = {}) {
|
|||
)
|
||||
}
|
||||
await discardResponseBody(response)
|
||||
return location
|
||||
return { location, response }
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -129,8 +142,8 @@ async function fetchString(url, opts = {}) {
|
|||
}
|
||||
|
||||
async function fetchStringWithResponse(url, opts = {}) {
|
||||
const { fetchOpts } = parseOpts(opts)
|
||||
const response = await performRequest(url, fetchOpts)
|
||||
const { fetchOpts, detachSignal } = parseOpts(opts)
|
||||
const response = await performRequest(url, fetchOpts, detachSignal)
|
||||
if (!response.ok) {
|
||||
const body = await maybeGetResponseBody(response)
|
||||
throw new RequestFailedError(url, opts, response, body)
|
||||
|
@ -165,13 +178,14 @@ function parseOpts(opts) {
|
|||
|
||||
const abortController = new AbortController()
|
||||
fetchOpts.signal = abortController.signal
|
||||
let detachSignal = () => {}
|
||||
if (opts.signal) {
|
||||
abortOnSignal(abortController, opts.signal)
|
||||
detachSignal = abortOnSignal(abortController, opts.signal)
|
||||
}
|
||||
if (opts.body instanceof Readable) {
|
||||
abortOnDestroyedRequest(abortController, fetchOpts.body)
|
||||
}
|
||||
return { fetchOpts, abortController }
|
||||
return { fetchOpts, abortController, detachSignal }
|
||||
}
|
||||
|
||||
function setupJsonBody(fetchOpts, json) {
|
||||
|
@ -195,6 +209,9 @@ function abortOnSignal(abortController, signal) {
|
|||
abortController.abort(signal.reason)
|
||||
}
|
||||
signal.addEventListener('abort', listener)
|
||||
return () => {
|
||||
signal.removeEventListener('abort', listener)
|
||||
}
|
||||
}
|
||||
|
||||
function abortOnDestroyedRequest(abortController, stream) {
|
||||
|
@ -213,11 +230,12 @@ function abortOnDestroyedResponse(abortController, response) {
|
|||
})
|
||||
}
|
||||
|
||||
async function performRequest(url, fetchOpts) {
|
||||
async function performRequest(url, fetchOpts, detachSignal) {
|
||||
let response
|
||||
try {
|
||||
response = await fetch(url, fetchOpts)
|
||||
} catch (err) {
|
||||
detachSignal()
|
||||
if (fetchOpts.body instanceof Readable) {
|
||||
fetchOpts.body.destroy()
|
||||
}
|
||||
|
@ -226,6 +244,7 @@ async function performRequest(url, fetchOpts) {
|
|||
method: fetchOpts.method ?? 'GET',
|
||||
})
|
||||
}
|
||||
response.body.on('close', detachSignal)
|
||||
if (fetchOpts.body instanceof Readable) {
|
||||
response.body.on('close', () => {
|
||||
if (!fetchOpts.body.readableEnded) {
|
||||
|
@ -297,6 +316,7 @@ module.exports = {
|
|||
fetchStreamWithResponse,
|
||||
fetchNothing,
|
||||
fetchRedirect,
|
||||
fetchRedirectWithResponse,
|
||||
fetchString,
|
||||
fetchStringWithResponse,
|
||||
RequestFailedError,
|
||||
|
|
|
@ -20,8 +20,8 @@
|
|||
"body-parser": "^1.20.3",
|
||||
"chai": "^4.3.6",
|
||||
"chai-as-promised": "^7.1.1",
|
||||
"express": "^4.21.0",
|
||||
"mocha": "^10.2.0",
|
||||
"express": "^4.21.2",
|
||||
"mocha": "^11.1.0",
|
||||
"typescript": "^5.0.4"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
const { expect } = require('chai')
|
||||
const fs = require('node:fs')
|
||||
const events = require('node:events')
|
||||
const { FetchError, AbortError } = require('node-fetch')
|
||||
const { Readable } = require('stream')
|
||||
const { once } = require('events')
|
||||
const { Readable } = require('node:stream')
|
||||
const { pipeline } = require('node:stream/promises')
|
||||
const { once } = require('node:events')
|
||||
const { TestServer } = require('./helpers/TestServer')
|
||||
const selfsigned = require('selfsigned')
|
||||
const {
|
||||
|
@ -24,13 +27,17 @@ const pems = selfsigned.generate(attrs, { days: 365 })
|
|||
const PRIVATE_KEY = pems.private
|
||||
const PUBLIC_CERT = pems.cert
|
||||
|
||||
const dns = require('dns')
|
||||
const dns = require('node:dns')
|
||||
const _originalLookup = dns.lookup
|
||||
// Custom DNS resolver function
|
||||
dns.lookup = (hostname, options, callback) => {
|
||||
if (hostname === 'example.com') {
|
||||
// If the hostname is our test case, return the ip address for the test server
|
||||
callback(null, '127.0.0.1', 4)
|
||||
if (options?.all) {
|
||||
callback(null, [{ address: '127.0.0.1', family: 4 }])
|
||||
} else {
|
||||
callback(null, '127.0.0.1', 4)
|
||||
}
|
||||
} else {
|
||||
// Otherwise, use the default lookup
|
||||
_originalLookup(hostname, options, callback)
|
||||
|
@ -199,6 +206,31 @@ describe('fetch-utils', function () {
|
|||
).to.be.rejectedWith(AbortError)
|
||||
expect(stream.destroyed).to.be.true
|
||||
})
|
||||
|
||||
it('detaches from signal on success', async function () {
|
||||
const signal = AbortSignal.timeout(10_000)
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const s = await fetchStream(this.url('/hello'), { signal })
|
||||
expect(events.getEventListeners(signal, 'abort')).to.have.length(1)
|
||||
await pipeline(s, fs.createWriteStream('/dev/null'))
|
||||
expect(events.getEventListeners(signal, 'abort')).to.have.length(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('detaches from signal on error', async function () {
|
||||
const signal = AbortSignal.timeout(10_000)
|
||||
for (let i = 0; i < 20; i++) {
|
||||
try {
|
||||
await fetchStream(this.url('/500'), { signal })
|
||||
} catch (err) {
|
||||
if (err instanceof RequestFailedError && err.response.status === 500)
|
||||
continue
|
||||
throw err
|
||||
} finally {
|
||||
expect(events.getEventListeners(signal, 'abort')).to.have.length(0)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchNothing', function () {
|
||||
|
@ -387,9 +419,16 @@ async function* infiniteIterator() {
|
|||
async function abortOnceReceived(func, server) {
|
||||
const controller = new AbortController()
|
||||
const promise = func(controller.signal)
|
||||
expect(events.getEventListeners(controller.signal, 'abort')).to.have.length(1)
|
||||
await once(server.events, 'request-received')
|
||||
controller.abort()
|
||||
return await promise
|
||||
try {
|
||||
return await promise
|
||||
} finally {
|
||||
expect(events.getEventListeners(controller.signal, 'abort')).to.have.length(
|
||||
0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function expectRequestAborted(req) {
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
const express = require('express')
|
||||
const bodyParser = require('body-parser')
|
||||
const { EventEmitter } = require('events')
|
||||
const http = require('http')
|
||||
const https = require('https')
|
||||
const { promisify } = require('util')
|
||||
const { EventEmitter } = require('node:events')
|
||||
const http = require('node:http')
|
||||
const https = require('node:https')
|
||||
const { promisify } = require('node:util')
|
||||
|
||||
class TestServer {
|
||||
constructor() {
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
node_modules/
|
3
libraries/logger/.gitignore
vendored
3
libraries/logger/.gitignore
vendored
|
@ -1,3 +0,0 @@
|
|||
node_modules
|
||||
|
||||
.npmrc
|
|
@ -1 +1 @@
|
|||
18.20.2
|
||||
22.17.0
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
logger
|
||||
--dependencies=None
|
||||
--docker-repos=gcr.io/overleaf-ops
|
||||
--docker-repos=us-east1-docker.pkg.dev/overleaf-ops/ol-docker
|
||||
--env-add=
|
||||
--env-pass-through=
|
||||
--esmock-loader=False
|
||||
--is-library=True
|
||||
--node-version=18.20.2
|
||||
--node-version=22.17.0
|
||||
--public-repo=False
|
||||
--script-version=4.5.0
|
||||
--script-version=4.7.0
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
const { fetchString } = require('@overleaf/fetch-utils')
|
||||
const fs = require('fs')
|
||||
const fs = require('node:fs')
|
||||
|
||||
class LogLevelChecker {
|
||||
constructor(logger, defaultLevel) {
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
const Stream = require('stream')
|
||||
const Stream = require('node:stream')
|
||||
const bunyan = require('bunyan')
|
||||
const GCPManager = require('./gcp-manager')
|
||||
const SentryManager = require('./sentry-manager')
|
||||
const Serializers = require('./serializers')
|
||||
const {
|
||||
FileLogLevelChecker,
|
||||
|
@ -15,8 +14,10 @@ const LoggingManager = {
|
|||
initialize(name) {
|
||||
this.isProduction =
|
||||
(process.env.NODE_ENV || '').toLowerCase() === 'production'
|
||||
const isTest = (process.env.NODE_ENV || '').toLowerCase() === 'test'
|
||||
this.defaultLevel =
|
||||
process.env.LOG_LEVEL || (this.isProduction ? 'info' : 'debug')
|
||||
process.env.LOG_LEVEL ||
|
||||
(this.isProduction ? 'info' : isTest ? 'fatal' : 'debug')
|
||||
this.loggerName = name
|
||||
this.logger = bunyan.createLogger({
|
||||
name,
|
||||
|
@ -33,10 +34,6 @@ const LoggingManager = {
|
|||
return this
|
||||
},
|
||||
|
||||
initializeErrorReporting(dsn, options) {
|
||||
this.sentryManager = new SentryManager()
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {Record<string, any>|string} attributes - Attributes to log (nice serialization for err, req, res)
|
||||
* @param {string} [message] - Optional message
|
||||
|
@ -68,9 +65,6 @@ const LoggingManager = {
|
|||
})
|
||||
}
|
||||
this.logger.error(attributes, message, ...Array.from(args))
|
||||
if (this.sentryManager) {
|
||||
this.sentryManager.captureExceptionRateLimited(attributes, message)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
|
@ -98,9 +92,6 @@ const LoggingManager = {
|
|||
*/
|
||||
fatal(attributes, message) {
|
||||
this.logger.fatal(attributes, message)
|
||||
if (this.sentryManager) {
|
||||
this.sentryManager.captureException(attributes, message, 'fatal')
|
||||
}
|
||||
},
|
||||
|
||||
_getOutputStreamConfig() {
|
||||
|
|
|
@ -23,12 +23,11 @@
|
|||
"@google-cloud/logging-bunyan": "^5.1.0",
|
||||
"@overleaf/fetch-utils": "*",
|
||||
"@overleaf/o-error": "*",
|
||||
"@sentry/node": "^6.13.2",
|
||||
"bunyan": "^1.8.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"chai": "^4.3.6",
|
||||
"mocha": "^10.2.0",
|
||||
"mocha": "^11.1.0",
|
||||
"sandboxed-module": "^2.0.4",
|
||||
"sinon": "^9.2.4",
|
||||
"sinon-chai": "^3.7.0",
|
||||
|
|
|
@ -1,106 +0,0 @@
|
|||
const Serializers = require('./serializers')
|
||||
const RATE_LIMIT_MAX_ERRORS = 5
|
||||
const RATE_LIMIT_INTERVAL_MS = 60000
|
||||
|
||||
class SentryManager {
|
||||
constructor(dsn, options) {
|
||||
this.Sentry = require('@sentry/node')
|
||||
this.Sentry.init({ dsn, ...options })
|
||||
// for rate limiting on sentry reporting
|
||||
this.lastErrorTimeStamp = 0
|
||||
this.lastErrorCount = 0
|
||||
}
|
||||
|
||||
captureExceptionRateLimited(attributes, message) {
|
||||
const now = Date.now()
|
||||
// have we recently reported an error?
|
||||
const recentSentryReport =
|
||||
now - this.lastErrorTimeStamp < RATE_LIMIT_INTERVAL_MS
|
||||
// if so, increment the error count
|
||||
if (recentSentryReport) {
|
||||
this.lastErrorCount++
|
||||
} else {
|
||||
this.lastErrorCount = 0
|
||||
this.lastErrorTimeStamp = now
|
||||
}
|
||||
// only report 5 errors every minute to avoid overload
|
||||
if (this.lastErrorCount < RATE_LIMIT_MAX_ERRORS) {
|
||||
// add a note if the rate limit has been hit
|
||||
const note =
|
||||
this.lastErrorCount + 1 === RATE_LIMIT_MAX_ERRORS
|
||||
? '(rate limited)'
|
||||
: ''
|
||||
// report the exception
|
||||
this.captureException(attributes, message, `error${note}`)
|
||||
}
|
||||
}
|
||||
|
||||
captureException(attributes, message, level) {
|
||||
// handle case of logger.error "message"
|
||||
if (typeof attributes === 'string') {
|
||||
attributes = { err: new Error(attributes) }
|
||||
}
|
||||
|
||||
// extract any error object
|
||||
let error = Serializers.err(attributes.err || attributes.error)
|
||||
|
||||
// avoid reporting errors twice
|
||||
for (const key in attributes) {
|
||||
const value = attributes[key]
|
||||
if (value instanceof Error && value.reportedToSentry) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// include our log message in the error report
|
||||
if (error == null) {
|
||||
if (typeof message === 'string') {
|
||||
error = { message }
|
||||
}
|
||||
} else if (message != null) {
|
||||
attributes.description = message
|
||||
}
|
||||
|
||||
// report the error
|
||||
if (error != null) {
|
||||
// capture attributes and use *_id objects as tags
|
||||
const tags = {}
|
||||
const extra = {}
|
||||
for (const key in attributes) {
|
||||
let value = attributes[key]
|
||||
if (Serializers[key]) {
|
||||
value = Serializers[key](value)
|
||||
}
|
||||
if (key.match(/_id/) && typeof value === 'string') {
|
||||
tags[key] = value
|
||||
}
|
||||
extra[key] = value
|
||||
}
|
||||
|
||||
// OError integration
|
||||
extra.info = error.info
|
||||
delete error.info
|
||||
|
||||
// Sentry wants to receive an Error instance.
|
||||
const errInstance = new Error(error.message)
|
||||
Object.assign(errInstance, error)
|
||||
|
||||
try {
|
||||
// send the error to sentry
|
||||
this.Sentry.captureException(errInstance, { tags, extra, level })
|
||||
|
||||
// put a flag on the errors to avoid reporting them multiple times
|
||||
for (const key in attributes) {
|
||||
const value = attributes[key]
|
||||
if (value instanceof Error) {
|
||||
value.reportedToSentry = true
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// ignore Sentry errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SentryManager
|
|
@ -8,4 +8,9 @@ chai.use(sinonChai)
|
|||
|
||||
SandboxedModule.configure({
|
||||
globals: { Buffer, JSON, console, process },
|
||||
sourceTransformers: {
|
||||
removeNodePrefix: function (source) {
|
||||
return source.replace(/require\(['"]node:/g, "require('")
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
const Path = require('path')
|
||||
const { promisify } = require('util')
|
||||
const Path = require('node:path')
|
||||
const { promisify } = require('node:util')
|
||||
const SandboxedModule = require('sandboxed-module')
|
||||
const sinon = require('sinon')
|
||||
const { expect } = require('chai')
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
const SandboxedModule = require('sandboxed-module')
|
||||
const bunyan = require('bunyan')
|
||||
const { expect } = require('chai')
|
||||
const path = require('path')
|
||||
const path = require('node:path')
|
||||
const sinon = require('sinon')
|
||||
|
||||
const MODULE_PATH = path.join(__dirname, '../../logging-manager.js')
|
||||
|
@ -43,20 +43,14 @@ describe('LoggingManager', function () {
|
|||
.stub()
|
||||
.returns(this.GCEMetadataLogLevelChecker),
|
||||
}
|
||||
this.SentryManager = {
|
||||
captureException: sinon.stub(),
|
||||
captureExceptionRateLimited: sinon.stub(),
|
||||
}
|
||||
this.LoggingManager = SandboxedModule.require(MODULE_PATH, {
|
||||
requires: {
|
||||
bunyan: this.Bunyan,
|
||||
'./log-level-checker': this.LogLevelChecker,
|
||||
'./sentry-manager': sinon.stub().returns(this.SentryManager),
|
||||
},
|
||||
})
|
||||
this.loggerName = 'test'
|
||||
this.logger = this.LoggingManager.initialize(this.loggerName)
|
||||
this.logger.initializeErrorReporting('test_dsn')
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
|
@ -160,13 +154,6 @@ describe('LoggingManager', function () {
|
|||
})
|
||||
})
|
||||
|
||||
describe('logger.error', function () {
|
||||
it('should report errors to Sentry', function () {
|
||||
this.logger.error({ foo: 'bar' }, 'message')
|
||||
expect(this.SentryManager.captureExceptionRateLimited).to.have.been.called
|
||||
})
|
||||
})
|
||||
|
||||
describe('ringbuffer', function () {
|
||||
beforeEach(function () {
|
||||
this.logBufferMock = [
|
||||
|
|
|
@ -1,247 +0,0 @@
|
|||
const Path = require('path')
|
||||
const SandboxedModule = require('sandboxed-module')
|
||||
const { expect } = require('chai')
|
||||
const sinon = require('sinon')
|
||||
|
||||
const MODULE_PATH = Path.join(__dirname, '../../sentry-manager.js')
|
||||
|
||||
describe('SentryManager', function () {
|
||||
beforeEach(function () {
|
||||
this.clock = sinon.useFakeTimers(Date.now())
|
||||
this.Sentry = {
|
||||
init: sinon.stub(),
|
||||
captureException: sinon.stub(),
|
||||
}
|
||||
this.SentryManager = SandboxedModule.require(MODULE_PATH, {
|
||||
requires: {
|
||||
'@sentry/node': this.Sentry,
|
||||
},
|
||||
})
|
||||
this.sentryManager = new this.SentryManager('test_dsn')
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
this.clock.restore()
|
||||
})
|
||||
|
||||
describe('captureExceptionRateLimited', function () {
|
||||
it('should report a single error to sentry', function () {
|
||||
this.sentryManager.captureExceptionRateLimited({ foo: 'bar' }, 'message')
|
||||
expect(this.Sentry.captureException).to.have.been.calledOnce
|
||||
})
|
||||
|
||||
it('should report the same error to sentry only once', function () {
|
||||
const error1 = new Error('this is the error')
|
||||
this.sentryManager.captureExceptionRateLimited(
|
||||
{ foo: error1 },
|
||||
'first message'
|
||||
)
|
||||
this.sentryManager.captureExceptionRateLimited(
|
||||
{ bar: error1 },
|
||||
'second message'
|
||||
)
|
||||
expect(this.Sentry.captureException).to.have.been.calledOnce
|
||||
})
|
||||
|
||||
it('should report two different errors to sentry individually', function () {
|
||||
const error1 = new Error('this is the error')
|
||||
const error2 = new Error('this is the error')
|
||||
this.sentryManager.captureExceptionRateLimited(
|
||||
{ foo: error1 },
|
||||
'first message'
|
||||
)
|
||||
this.sentryManager.captureExceptionRateLimited(
|
||||
{ bar: error2 },
|
||||
'second message'
|
||||
)
|
||||
expect(this.Sentry.captureException).to.have.been.calledTwice
|
||||
})
|
||||
|
||||
it('for multiple errors should only report a maximum of 5 errors to sentry', function () {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
this.sentryManager.captureExceptionRateLimited(
|
||||
{ foo: 'bar' },
|
||||
'message'
|
||||
)
|
||||
}
|
||||
expect(this.Sentry.captureException).to.have.callCount(5)
|
||||
})
|
||||
|
||||
it('for multiple errors with a minute delay should report 10 errors to sentry', function () {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
this.sentryManager.captureExceptionRateLimited(
|
||||
{ foo: 'bar' },
|
||||
'message'
|
||||
)
|
||||
}
|
||||
expect(this.Sentry.captureException).to.have.callCount(5)
|
||||
|
||||
// allow a minute to pass
|
||||
this.clock.tick(61 * 1000)
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
this.sentryManager.captureExceptionRateLimited(
|
||||
{ foo: 'bar' },
|
||||
'message'
|
||||
)
|
||||
}
|
||||
expect(this.Sentry.captureException).to.have.callCount(10)
|
||||
})
|
||||
})
|
||||
|
||||
describe('captureException', function () {
|
||||
it('should remove the path from fs errors', function () {
|
||||
const fsError = new Error(
|
||||
"Error: ENOENT: no such file or directory, stat '/tmp/3279b8d0-da10-11e8-8255-efd98985942b'"
|
||||
)
|
||||
fsError.path = '/tmp/3279b8d0-da10-11e8-8255-efd98985942b'
|
||||
this.sentryManager.captureException({ err: fsError }, 'message', 'error')
|
||||
expect(this.Sentry.captureException).to.have.been.calledWith(
|
||||
sinon.match.has(
|
||||
'message',
|
||||
'Error: ENOENT: no such file or directory, stat'
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('should sanitize error', function () {
|
||||
const err = {
|
||||
name: 'CustomError',
|
||||
message: 'hello',
|
||||
_oErrorTags: [{ stack: 'here:1', info: { one: 1 } }],
|
||||
stack: 'here:0',
|
||||
info: { key: 'value' },
|
||||
code: 42,
|
||||
signal: 9,
|
||||
path: '/foo',
|
||||
}
|
||||
this.sentryManager.captureException({ err }, 'message', 'error')
|
||||
const expectedErr = {
|
||||
name: 'CustomError',
|
||||
message: 'hello',
|
||||
stack: 'here:0\nhere:1',
|
||||
code: 42,
|
||||
signal: 9,
|
||||
path: '/foo',
|
||||
}
|
||||
expect(this.Sentry.captureException).to.have.been.calledWith(
|
||||
sinon.match(expectedErr),
|
||||
sinon.match({
|
||||
tags: sinon.match.any,
|
||||
level: sinon.match.any,
|
||||
extra: {
|
||||
description: 'message',
|
||||
info: sinon.match({
|
||||
one: 1,
|
||||
key: 'value',
|
||||
}),
|
||||
},
|
||||
})
|
||||
)
|
||||
// Chai is very picky with comparing Error instances. Go the long way of comparing all the fields manually.
|
||||
const gotErr = this.Sentry.captureException.args[0][0]
|
||||
for (const [key, wanted] of Object.entries(expectedErr)) {
|
||||
expect(gotErr).to.have.property(key, wanted)
|
||||
}
|
||||
})
|
||||
it('should sanitize request', function () {
|
||||
const req = {
|
||||
ip: '1.2.3.4',
|
||||
method: 'GET',
|
||||
url: '/foo',
|
||||
headers: {
|
||||
referer: 'abc',
|
||||
'content-length': 1337,
|
||||
'user-agent': 'curl',
|
||||
authorization: '42',
|
||||
},
|
||||
}
|
||||
this.sentryManager.captureException({ req }, 'message', 'error')
|
||||
const expectedReq = {
|
||||
remoteAddress: '1.2.3.4',
|
||||
method: 'GET',
|
||||
url: '/foo',
|
||||
headers: {
|
||||
referer: 'abc',
|
||||
'content-length': 1337,
|
||||
'user-agent': 'curl',
|
||||
},
|
||||
}
|
||||
expect(this.Sentry.captureException).to.have.been.calledWith(
|
||||
sinon.match({
|
||||
message: 'message',
|
||||
}),
|
||||
sinon.match({
|
||||
tags: sinon.match.any,
|
||||
level: sinon.match.any,
|
||||
extra: {
|
||||
info: sinon.match.any,
|
||||
req: expectedReq,
|
||||
},
|
||||
})
|
||||
)
|
||||
expect(this.Sentry.captureException.args[0][1].extra.req).to.deep.equal(
|
||||
expectedReq
|
||||
)
|
||||
})
|
||||
it('should sanitize response', function () {
|
||||
const res = {
|
||||
statusCode: 417,
|
||||
body: Buffer.from('foo'),
|
||||
getHeader(key) {
|
||||
expect(key).to.be.oneOf(['content-length'])
|
||||
if (key === 'content-length') return 1337
|
||||
},
|
||||
}
|
||||
this.sentryManager.captureException({ res }, 'message', 'error')
|
||||
const expectedRes = {
|
||||
statusCode: 417,
|
||||
headers: {
|
||||
'content-length': 1337,
|
||||
},
|
||||
}
|
||||
expect(this.Sentry.captureException).to.have.been.calledWith(
|
||||
sinon.match({
|
||||
message: 'message',
|
||||
}),
|
||||
sinon.match({
|
||||
tags: sinon.match.any,
|
||||
level: sinon.match.any,
|
||||
extra: {
|
||||
info: sinon.match.any,
|
||||
res: expectedRes,
|
||||
},
|
||||
})
|
||||
)
|
||||
expect(this.Sentry.captureException.args[0][1].extra.res).to.deep.equal(
|
||||
expectedRes
|
||||
)
|
||||
})
|
||||
|
||||
describe('reportedToSentry', function () {
|
||||
it('should mark the error as reported to sentry', function () {
|
||||
const err = new Error()
|
||||
this.sentryManager.captureException({ err }, 'message')
|
||||
expect(this.Sentry.captureException).to.have.been.called
|
||||
expect(err.reportedToSentry).to.equal(true)
|
||||
})
|
||||
|
||||
it('should mark two errors as reported to sentry', function () {
|
||||
const err1 = new Error()
|
||||
const err2 = new Error()
|
||||
this.sentryManager.captureException({ err: err1, err2 }, 'message')
|
||||
expect(this.Sentry.captureException).to.have.been.called
|
||||
expect(err1.reportedToSentry).to.equal(true)
|
||||
expect(err2.reportedToSentry).to.equal(true)
|
||||
})
|
||||
|
||||
it('should not mark arbitrary objects as reported to sentry', function () {
|
||||
const err = new Error()
|
||||
const ctx = { foo: 'bar' }
|
||||
this.sentryManager.captureException({ err, ctx }, 'message')
|
||||
expect(this.Sentry.captureException).to.have.been.called
|
||||
expect(ctx.reportedToSentry).not.to.exist
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
|
@ -1 +0,0 @@
|
|||
node_modules/
|
3
libraries/metrics/.gitignore
vendored
3
libraries/metrics/.gitignore
vendored
|
@ -1,3 +0,0 @@
|
|||
node_modules
|
||||
|
||||
.npmrc
|
|
@ -1 +1 @@
|
|||
18.20.2
|
||||
22.17.0
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
metrics
|
||||
--dependencies=None
|
||||
--docker-repos=gcr.io/overleaf-ops
|
||||
--docker-repos=us-east1-docker.pkg.dev/overleaf-ops/ol-docker
|
||||
--env-add=
|
||||
--env-pass-through=
|
||||
--esmock-loader=False
|
||||
--is-library=True
|
||||
--node-version=18.20.2
|
||||
--node-version=22.17.0
|
||||
--public-repo=False
|
||||
--script-version=4.5.0
|
||||
--script-version=4.7.0
|
||||
|
|
|
@ -5,6 +5,8 @@
|
|||
* before any other module to support code instrumentation.
|
||||
*/
|
||||
|
||||
const metricsModuleImportStartTime = performance.now()
|
||||
|
||||
const APP_NAME = process.env.METRICS_APP_NAME || 'unknown'
|
||||
const BUILD_VERSION = process.env.BUILD_VERSION
|
||||
const ENABLE_PROFILE_AGENT = process.env.ENABLE_PROFILE_AGENT === 'true'
|
||||
|
@ -88,7 +90,7 @@ function initializeProfileAgent() {
|
|||
}
|
||||
|
||||
function initializePrometheus() {
|
||||
const os = require('os')
|
||||
const os = require('node:os')
|
||||
const promClient = require('prom-client')
|
||||
promClient.register.setDefaultLabels({ app: APP_NAME, host: os.hostname() })
|
||||
promClient.collectDefaultMetrics({ timeout: 5000, prefix: '' })
|
||||
|
@ -103,3 +105,5 @@ function recordProcessStart() {
|
|||
const metrics = require('.')
|
||||
metrics.inc('process_startup')
|
||||
}
|
||||
|
||||
module.exports = { metricsModuleImportStartTime }
|
||||
|
|
|
@ -6,8 +6,8 @@
|
|||
* logged along with the corresponding information from /proc/net/tcp.
|
||||
*/
|
||||
|
||||
const fs = require('fs')
|
||||
const diagnosticsChannel = require('diagnostics_channel')
|
||||
const fs = require('node:fs')
|
||||
const diagnosticsChannel = require('node:diagnostics_channel')
|
||||
|
||||
const SOCKET_MONITOR_INTERVAL = 60 * 1000
|
||||
// set the threshold for logging leaked sockets in minutes, defaults to 15
|
||||
|
|
|
@ -11,13 +11,13 @@ const seconds = 1000
|
|||
// In Node 0.10 the default is 5, which means only 5 open connections at one.
|
||||
// Node 0.12 has a default of Infinity. Make sure we have no limit set,
|
||||
// regardless of Node version.
|
||||
require('http').globalAgent.maxSockets = Infinity
|
||||
require('https').globalAgent.maxSockets = Infinity
|
||||
require('node:http').globalAgent.maxSockets = Infinity
|
||||
require('node:https').globalAgent.maxSockets = Infinity
|
||||
|
||||
const SOCKETS_HTTP = require('http').globalAgent.sockets
|
||||
const SOCKETS_HTTPS = require('https').globalAgent.sockets
|
||||
const FREE_SOCKETS_HTTP = require('http').globalAgent.freeSockets
|
||||
const FREE_SOCKETS_HTTPS = require('https').globalAgent.freeSockets
|
||||
const SOCKETS_HTTP = require('node:http').globalAgent.sockets
|
||||
const SOCKETS_HTTPS = require('node:https').globalAgent.sockets
|
||||
const FREE_SOCKETS_HTTP = require('node:http').globalAgent.freeSockets
|
||||
const FREE_SOCKETS_HTTPS = require('node:https').globalAgent.freeSockets
|
||||
|
||||
// keep track of set gauges and reset them in the next collection cycle
|
||||
const SEEN_HOSTS_HTTP = new Set()
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"@google-cloud/opentelemetry-cloud-trace-exporter": "^2.1.0",
|
||||
"@google-cloud/profiler": "^6.0.0",
|
||||
"@google-cloud/profiler": "^6.0.3",
|
||||
"@opentelemetry/api": "^1.4.1",
|
||||
"@opentelemetry/auto-instrumentations-node": "^0.39.1",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.41.2",
|
||||
|
@ -23,7 +23,7 @@
|
|||
"devDependencies": {
|
||||
"bunyan": "^1.0.0",
|
||||
"chai": "^4.3.6",
|
||||
"mocha": "^10.2.0",
|
||||
"mocha": "^11.1.0",
|
||||
"sandboxed-module": "^2.0.4",
|
||||
"sinon": "^9.2.4",
|
||||
"typescript": "^5.0.4"
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
const { promisify } = require('util')
|
||||
const os = require('os')
|
||||
const http = require('http')
|
||||
const { promisify } = require('node:util')
|
||||
const os = require('node:os')
|
||||
const http = require('node:http')
|
||||
const { expect } = require('chai')
|
||||
const Metrics = require('../..')
|
||||
|
||||
|
@ -316,7 +316,7 @@ async function checkSummaryValues(key, values) {
|
|||
for (const quantile of Object.keys(values)) {
|
||||
expect(found[quantile]).to.be.within(
|
||||
values[quantile] - 5,
|
||||
values[quantile] + 5,
|
||||
values[quantile] + 15,
|
||||
`quantile: ${quantile}`
|
||||
)
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
const chai = require('chai')
|
||||
const { expect } = chai
|
||||
const path = require('path')
|
||||
const path = require('node:path')
|
||||
const modulePath = path.join(__dirname, '../../../event_loop.js')
|
||||
const SandboxedModule = require('sandboxed-module')
|
||||
const sinon = require('sinon')
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
const Path = require('path')
|
||||
const Path = require('node:path')
|
||||
const SandboxedModule = require('sandboxed-module')
|
||||
const sinon = require('sinon')
|
||||
|
||||
|
|
1
libraries/mongo-utils/.nvmrc
Normal file
1
libraries/mongo-utils/.nvmrc
Normal file
|
@ -0,0 +1 @@
|
|||
22.17.0
|
321
libraries/mongo-utils/batchedUpdate.js
Normal file
321
libraries/mongo-utils/batchedUpdate.js
Normal file
|
@ -0,0 +1,321 @@
|
|||
// @ts-check
|
||||
/* eslint-disable no-console */
|
||||
const { ObjectId, ReadPreference } = require('mongodb')
|
||||
|
||||
const READ_PREFERENCE_SECONDARY =
|
||||
process.env.MONGO_HAS_SECONDARIES === 'true'
|
||||
? ReadPreference.secondary.mode
|
||||
: ReadPreference.secondaryPreferred.mode
|
||||
|
||||
const ONE_MONTH_IN_MS = 1000 * 60 * 60 * 24 * 31
|
||||
let ID_EDGE_PAST
|
||||
const ID_EDGE_FUTURE = objectIdFromMs(Date.now() + 1000)
|
||||
let BATCH_DESCENDING
|
||||
let BATCH_SIZE
|
||||
let VERBOSE_LOGGING
|
||||
let BATCH_RANGE_START
|
||||
let BATCH_RANGE_END
|
||||
let BATCH_MAX_TIME_SPAN_IN_MS
|
||||
let BATCHED_UPDATE_RUNNING = false
|
||||
|
||||
/**
|
||||
* @typedef {import("mongodb").Collection} Collection
|
||||
* @typedef {import("mongodb-legacy").Collection} LegacyCollection
|
||||
* @typedef {import("mongodb").Document} Document
|
||||
* @typedef {import("mongodb").FindOptions} FindOptions
|
||||
* @typedef {import("mongodb").UpdateFilter<Document>} UpdateDocument
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} BatchedUpdateOptions
|
||||
* @property {string} [BATCH_DESCENDING]
|
||||
* @property {string} [BATCH_LAST_ID]
|
||||
* @property {string} [BATCH_MAX_TIME_SPAN_IN_MS]
|
||||
* @property {string} [BATCH_RANGE_END]
|
||||
* @property {string} [BATCH_RANGE_START]
|
||||
* @property {string} [BATCH_SIZE]
|
||||
* @property {string} [VERBOSE_LOGGING]
|
||||
* @property {(progress: string) => Promise<void>} [trackProgress]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {BatchedUpdateOptions} options
|
||||
*/
|
||||
function refreshGlobalOptionsForBatchedUpdate(options = {}) {
|
||||
options = Object.assign({}, options, process.env)
|
||||
|
||||
BATCH_DESCENDING = options.BATCH_DESCENDING === 'true'
|
||||
BATCH_SIZE = parseInt(options.BATCH_SIZE || '1000', 10) || 1000
|
||||
VERBOSE_LOGGING = options.VERBOSE_LOGGING === 'true'
|
||||
if (options.BATCH_LAST_ID) {
|
||||
BATCH_RANGE_START = objectIdFromInput(options.BATCH_LAST_ID)
|
||||
} else if (options.BATCH_RANGE_START) {
|
||||
BATCH_RANGE_START = objectIdFromInput(options.BATCH_RANGE_START)
|
||||
} else {
|
||||
if (BATCH_DESCENDING) {
|
||||
BATCH_RANGE_START = ID_EDGE_FUTURE
|
||||
} else {
|
||||
BATCH_RANGE_START = ID_EDGE_PAST
|
||||
}
|
||||
}
|
||||
BATCH_MAX_TIME_SPAN_IN_MS = parseInt(
|
||||
options.BATCH_MAX_TIME_SPAN_IN_MS || ONE_MONTH_IN_MS.toString(),
|
||||
10
|
||||
)
|
||||
if (options.BATCH_RANGE_END) {
|
||||
BATCH_RANGE_END = objectIdFromInput(options.BATCH_RANGE_END)
|
||||
} else {
|
||||
if (BATCH_DESCENDING) {
|
||||
BATCH_RANGE_END = ID_EDGE_PAST
|
||||
} else {
|
||||
BATCH_RANGE_END = ID_EDGE_FUTURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Collection | LegacyCollection} collection
|
||||
* @param {Document} query
|
||||
* @param {ObjectId} start
|
||||
* @param {ObjectId} end
|
||||
* @param {Document} projection
|
||||
* @param {FindOptions} findOptions
|
||||
* @return {Promise<Array<Document>>}
|
||||
*/
|
||||
async function getNextBatch(
|
||||
collection,
|
||||
query,
|
||||
start,
|
||||
end,
|
||||
projection,
|
||||
findOptions
|
||||
) {
|
||||
if (BATCH_DESCENDING) {
|
||||
query._id = {
|
||||
$gt: end,
|
||||
$lte: start,
|
||||
}
|
||||
} else {
|
||||
query._id = {
|
||||
$gt: start,
|
||||
$lte: end,
|
||||
}
|
||||
}
|
||||
return await collection
|
||||
.find(query, findOptions)
|
||||
.project(projection)
|
||||
.sort({ _id: BATCH_DESCENDING ? -1 : 1 })
|
||||
.limit(BATCH_SIZE)
|
||||
.toArray()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Collection | LegacyCollection} collection
|
||||
* @param {Array<Document>} nextBatch
|
||||
* @param {UpdateDocument} update
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async function performUpdate(collection, nextBatch, update) {
|
||||
await collection.updateMany(
|
||||
{ _id: { $in: nextBatch.map(entry => entry._id) } },
|
||||
update
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} input
|
||||
* @return {ObjectId}
|
||||
*/
|
||||
function objectIdFromInput(input) {
|
||||
if (input.includes('T')) {
|
||||
const t = new Date(input).getTime()
|
||||
if (Number.isNaN(t)) throw new Error(`${input} is not a valid date`)
|
||||
return objectIdFromMs(t)
|
||||
} else {
|
||||
return new ObjectId(input)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ObjectId} objectId
|
||||
* @return {string}
|
||||
*/
|
||||
function renderObjectId(objectId) {
|
||||
return `${objectId} (${objectId.getTimestamp().toISOString()})`
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} ms
|
||||
* @return {ObjectId}
|
||||
*/
|
||||
function objectIdFromMs(ms) {
|
||||
return ObjectId.createFromTime(ms / 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ObjectId} id
|
||||
* @return {number}
|
||||
*/
|
||||
function getMsFromObjectId(id) {
|
||||
return id.getTimestamp().getTime()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ObjectId} start
|
||||
* @return {ObjectId}
|
||||
*/
|
||||
function getNextEnd(start) {
|
||||
let end
|
||||
if (BATCH_DESCENDING) {
|
||||
end = objectIdFromMs(getMsFromObjectId(start) - BATCH_MAX_TIME_SPAN_IN_MS)
|
||||
if (getMsFromObjectId(end) <= getMsFromObjectId(BATCH_RANGE_END)) {
|
||||
end = BATCH_RANGE_END
|
||||
}
|
||||
} else {
|
||||
end = objectIdFromMs(getMsFromObjectId(start) + BATCH_MAX_TIME_SPAN_IN_MS)
|
||||
if (getMsFromObjectId(end) >= getMsFromObjectId(BATCH_RANGE_END)) {
|
||||
end = BATCH_RANGE_END
|
||||
}
|
||||
}
|
||||
return end
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Collection | LegacyCollection} collection
|
||||
* @return {Promise<ObjectId|null>}
|
||||
*/
|
||||
async function getIdEdgePast(collection) {
|
||||
const [first] = await collection
|
||||
.find({})
|
||||
.project({ _id: 1 })
|
||||
.sort({ _id: 1 })
|
||||
.limit(1)
|
||||
.toArray()
|
||||
if (!first) return null
|
||||
// Go one second further into the past in order to include the first entry via
|
||||
// first._id > ID_EDGE_PAST
|
||||
return objectIdFromMs(Math.max(0, getMsFromObjectId(first._id) - 1000))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Collection | LegacyCollection} collection
|
||||
* @param {Document} query
|
||||
* @param {UpdateDocument | ((batch: Array<Document>) => Promise<void>)} update
|
||||
* @param {Document} [projection]
|
||||
* @param {FindOptions} [findOptions]
|
||||
* @param {BatchedUpdateOptions} [batchedUpdateOptions]
|
||||
*/
|
||||
async function batchedUpdate(
|
||||
collection,
|
||||
query,
|
||||
update,
|
||||
projection,
|
||||
findOptions,
|
||||
batchedUpdateOptions = {}
|
||||
) {
|
||||
// only a single batchedUpdate can run at a time due to global variables
|
||||
if (BATCHED_UPDATE_RUNNING) {
|
||||
throw new Error('batchedUpdate is already running')
|
||||
}
|
||||
try {
|
||||
BATCHED_UPDATE_RUNNING = true
|
||||
ID_EDGE_PAST = await getIdEdgePast(collection)
|
||||
if (!ID_EDGE_PAST) {
|
||||
console.warn(
|
||||
`The collection ${collection.collectionName} appears to be empty.`
|
||||
)
|
||||
return 0
|
||||
}
|
||||
refreshGlobalOptionsForBatchedUpdate(batchedUpdateOptions)
|
||||
const { trackProgress = async progress => console.warn(progress) } =
|
||||
batchedUpdateOptions
|
||||
|
||||
findOptions = findOptions || {}
|
||||
findOptions.readPreference = READ_PREFERENCE_SECONDARY
|
||||
|
||||
projection = projection || { _id: 1 }
|
||||
let nextBatch
|
||||
let updated = 0
|
||||
let start = BATCH_RANGE_START
|
||||
|
||||
while (start !== BATCH_RANGE_END) {
|
||||
let end = getNextEnd(start)
|
||||
nextBatch = await getNextBatch(
|
||||
collection,
|
||||
query,
|
||||
start,
|
||||
end,
|
||||
projection,
|
||||
findOptions
|
||||
)
|
||||
if (nextBatch.length > 0) {
|
||||
end = nextBatch[nextBatch.length - 1]._id
|
||||
updated += nextBatch.length
|
||||
|
||||
if (VERBOSE_LOGGING) {
|
||||
console.log(
|
||||
`Running update on batch with ids ${JSON.stringify(
|
||||
nextBatch.map(entry => entry._id)
|
||||
)}`
|
||||
)
|
||||
}
|
||||
await trackProgress(
|
||||
`Running update on batch ending ${renderObjectId(end)}`
|
||||
)
|
||||
|
||||
if (typeof update === 'function') {
|
||||
await update(nextBatch)
|
||||
} else {
|
||||
await performUpdate(collection, nextBatch, update)
|
||||
}
|
||||
}
|
||||
await trackProgress(`Completed batch ending ${renderObjectId(end)}`)
|
||||
start = end
|
||||
}
|
||||
return updated
|
||||
} finally {
|
||||
BATCHED_UPDATE_RUNNING = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Collection | LegacyCollection} collection
|
||||
* @param {Document} query
|
||||
* @param {UpdateDocument | ((batch: Array<Object>) => Promise<void>)} update
|
||||
* @param {Document} [projection]
|
||||
* @param {FindOptions} [findOptions]
|
||||
* @param {BatchedUpdateOptions} [batchedUpdateOptions]
|
||||
*/
|
||||
function batchedUpdateWithResultHandling(
|
||||
collection,
|
||||
query,
|
||||
update,
|
||||
projection,
|
||||
findOptions,
|
||||
batchedUpdateOptions
|
||||
) {
|
||||
batchedUpdate(
|
||||
collection,
|
||||
query,
|
||||
update,
|
||||
projection,
|
||||
findOptions,
|
||||
batchedUpdateOptions
|
||||
)
|
||||
.then(processed => {
|
||||
console.error({ processed })
|
||||
process.exit(0)
|
||||
})
|
||||
.catch(error => {
|
||||
console.error({ error })
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
READ_PREFERENCE_SECONDARY,
|
||||
objectIdFromInput,
|
||||
renderObjectId,
|
||||
batchedUpdate,
|
||||
batchedUpdateWithResultHandling,
|
||||
}
|
10
libraries/mongo-utils/buildscript.txt
Normal file
10
libraries/mongo-utils/buildscript.txt
Normal file
|
@ -0,0 +1,10 @@
|
|||
mongo-utils
|
||||
--dependencies=None
|
||||
--docker-repos=us-east1-docker.pkg.dev/overleaf-ops/ol-docker
|
||||
--env-add=
|
||||
--env-pass-through=
|
||||
--esmock-loader=False
|
||||
--is-library=True
|
||||
--node-version=22.17.0
|
||||
--public-repo=False
|
||||
--script-version=4.7.0
|
0
libraries/mongo-utils/index.js
Normal file
0
libraries/mongo-utils/index.js
Normal file
30
libraries/mongo-utils/package.json
Normal file
30
libraries/mongo-utils/package.json
Normal file
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"name": "@overleaf/mongo-utils",
|
||||
"version": "0.0.1",
|
||||
"description": "utilities to help working with mongo",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "npm run lint && npm run format && npm run types:check && npm run test:unit",
|
||||
"test:unit": "mocha --exit test/**/*.{js,cjs}",
|
||||
"lint": "eslint --ext .js --ext .cjs --ext .ts --max-warnings 0 --format unix .",
|
||||
"lint:fix": "eslint --fix --ext .js --ext .cjs --ext .ts .",
|
||||
"format": "prettier --list-different $PWD/'**/*.{js,cjs,ts}'",
|
||||
"format:fix": "prettier --write $PWD/'**/*.{js,cjs,ts}'",
|
||||
"test:ci": "npm run test:unit",
|
||||
"types:check": "tsc --noEmit"
|
||||
},
|
||||
"author": "Overleaf (https://www.overleaf.com)",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"mongodb": "6.12.0",
|
||||
"mongodb-legacy": "6.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"chai": "^4.3.6",
|
||||
"mocha": "^11.1.0",
|
||||
"sandboxed-module": "^2.0.4",
|
||||
"sinon": "^9.2.4",
|
||||
"sinon-chai": "^3.7.0",
|
||||
"typescript": "^5.0.4"
|
||||
}
|
||||
}
|
11
libraries/mongo-utils/test/setup.js
Normal file
11
libraries/mongo-utils/test/setup.js
Normal file
|
@ -0,0 +1,11 @@
|
|||
const chai = require('chai')
|
||||
const sinonChai = require('sinon-chai')
|
||||
const SandboxedModule = require('sandboxed-module')
|
||||
|
||||
// Chai configuration
|
||||
chai.should()
|
||||
chai.use(sinonChai)
|
||||
|
||||
SandboxedModule.configure({
|
||||
globals: { Buffer, JSON, console, process },
|
||||
})
|
7
libraries/mongo-utils/tsconfig.json
Normal file
7
libraries/mongo-utils/tsconfig.json
Normal file
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"extends": "../../tsconfig.backend.json",
|
||||
"include": [
|
||||
"**/*.js",
|
||||
"**/*.cjs"
|
||||
]
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
node_modules/
|
5
libraries/o-error/.gitignore
vendored
5
libraries/o-error/.gitignore
vendored
|
@ -1,5 +0,0 @@
|
|||
.nyc_output
|
||||
coverage
|
||||
node_modules/
|
||||
|
||||
.npmrc
|
|
@ -1 +1 @@
|
|||
18.20.2
|
||||
22.17.0
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
o-error
|
||||
--dependencies=None
|
||||
--docker-repos=gcr.io/overleaf-ops
|
||||
--docker-repos=us-east1-docker.pkg.dev/overleaf-ops/ol-docker
|
||||
--env-add=
|
||||
--env-pass-through=
|
||||
--esmock-loader=False
|
||||
--is-library=True
|
||||
--node-version=18.20.2
|
||||
--node-version=22.17.0
|
||||
--public-repo=False
|
||||
--script-version=4.5.0
|
||||
--script-version=4.7.0
|
||||
|
|
|
@ -70,7 +70,7 @@ sayHi3(43, (err, result) => {
|
|||
}
|
||||
})
|
||||
|
||||
const promisify = require('util').promisify
|
||||
const promisify = require('node:util').promisify
|
||||
demoDatabase.findUserAsync = promisify(demoDatabase.findUser)
|
||||
|
||||
async function sayHi4NoHandling(userId) {
|
||||
|
|
|
@ -1,20 +1,34 @@
|
|||
// @ts-check
|
||||
|
||||
/**
|
||||
* Light-weight helpers for handling JavaScript Errors in node.js and the
|
||||
* browser.
|
||||
*/
|
||||
class OError extends Error {
|
||||
/**
|
||||
* The error that is the underlying cause of this error
|
||||
*
|
||||
* @type {unknown}
|
||||
*/
|
||||
cause
|
||||
|
||||
/**
|
||||
* List of errors encountered as the callback chain is unwound
|
||||
*
|
||||
* @type {TaggedError[] | undefined}
|
||||
*/
|
||||
_oErrorTags
|
||||
|
||||
/**
|
||||
* @param {string} message as for built-in Error
|
||||
* @param {Object} [info] extra data to attach to the error
|
||||
* @param {Error} [cause] the internal error that caused this error
|
||||
* @param {unknown} [cause] the internal error that caused this error
|
||||
*/
|
||||
constructor(message, info, cause) {
|
||||
super(message)
|
||||
this.name = this.constructor.name
|
||||
if (info) this.info = info
|
||||
if (cause) this.cause = cause
|
||||
/** @private @type {Array<TaggedError> | undefined} */
|
||||
this._oErrorTags // eslint-disable-line
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -31,7 +45,7 @@ class OError extends Error {
|
|||
/**
|
||||
* Wrap the given error, which caused this error.
|
||||
*
|
||||
* @param {Error} cause the internal error that caused this error
|
||||
* @param {unknown} cause the internal error that caused this error
|
||||
* @return {this}
|
||||
*/
|
||||
withCause(cause) {
|
||||
|
@ -65,13 +79,16 @@ class OError extends Error {
|
|||
* }
|
||||
* }
|
||||
*
|
||||
* @param {Error} error the error to tag
|
||||
* @template {unknown} E
|
||||
* @param {E} error the error to tag
|
||||
* @param {string} [message] message with which to tag `error`
|
||||
* @param {Object} [info] extra data with wich to tag `error`
|
||||
* @return {Error} the modified `error` argument
|
||||
* @return {E} the modified `error` argument
|
||||
*/
|
||||
static tag(error, message, info) {
|
||||
const oError = /** @type{OError} */ (error)
|
||||
const oError = /** @type {{ _oErrorTags: TaggedError[] | undefined }} */ (
|
||||
error
|
||||
)
|
||||
|
||||
if (!oError._oErrorTags) oError._oErrorTags = []
|
||||
|
||||
|
@ -102,7 +119,7 @@ class OError extends Error {
|
|||
*
|
||||
* If an info property is repeated, the last one wins.
|
||||
*
|
||||
* @param {Error | null | undefined} error any error (may or may not be an `OError`)
|
||||
* @param {unknown} error any error (may or may not be an `OError`)
|
||||
* @return {Object}
|
||||
*/
|
||||
static getFullInfo(error) {
|
||||
|
@ -129,7 +146,7 @@ class OError extends Error {
|
|||
* Return the `stack` property from `error`, including the `stack`s for any
|
||||
* tagged errors added with `OError.tag` and for any `cause`s.
|
||||
*
|
||||
* @param {Error | null | undefined} error any error (may or may not be an `OError`)
|
||||
* @param {unknown} error any error (may or may not be an `OError`)
|
||||
* @return {string}
|
||||
*/
|
||||
static getFullStack(error) {
|
||||
|
@ -143,7 +160,7 @@ class OError extends Error {
|
|||
stack += `\n${oError._oErrorTags.map(tag => tag.stack).join('\n')}`
|
||||
}
|
||||
|
||||
const causeStack = oError.cause && OError.getFullStack(oError.cause)
|
||||
const causeStack = OError.getFullStack(oError.cause)
|
||||
if (causeStack) {
|
||||
stack += '\ncaused by:\n' + indent(causeStack)
|
||||
}
|
||||
|
|
|
@ -34,7 +34,7 @@
|
|||
"@types/chai": "^4.3.0",
|
||||
"@types/node": "^18.17.4",
|
||||
"chai": "^4.3.6",
|
||||
"mocha": "^10.2.0",
|
||||
"mocha": "^11.1.0",
|
||||
"typescript": "^5.0.4"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
const { expect } = require('chai')
|
||||
const { promisify } = require('util')
|
||||
const { promisify } = require('node:util')
|
||||
|
||||
const OError = require('..')
|
||||
|
||||
|
@ -268,6 +268,11 @@ describe('utils', function () {
|
|||
expect(OError.getFullInfo(null)).to.deep.equal({})
|
||||
})
|
||||
|
||||
it('works when given a string', function () {
|
||||
const err = 'not an error instance'
|
||||
expect(OError.getFullInfo(err)).to.deep.equal({})
|
||||
})
|
||||
|
||||
it('works on a normal error', function () {
|
||||
const err = new Error('foo')
|
||||
expect(OError.getFullInfo(err)).to.deep.equal({})
|
||||
|
|
|
@ -35,6 +35,14 @@ describe('OError', function () {
|
|||
expect(err2.cause.message).to.equal('cause 2')
|
||||
})
|
||||
|
||||
it('accepts non-Error causes', function () {
|
||||
const err1 = new OError('foo', {}, 'not-an-error')
|
||||
expect(err1.cause).to.equal('not-an-error')
|
||||
|
||||
const err2 = new OError('foo').withCause('not-an-error')
|
||||
expect(err2.cause).to.equal('not-an-error')
|
||||
})
|
||||
|
||||
it('handles a custom error type with a cause', function () {
|
||||
function doSomethingBadInternally() {
|
||||
throw new Error('internal error')
|
||||
|
|
|
@ -23,7 +23,7 @@ exports.expectError = function OErrorExpectError(e, expected) {
|
|||
).to.be.true
|
||||
|
||||
expect(
|
||||
require('util').types.isNativeError(e),
|
||||
require('node:util').types.isNativeError(e),
|
||||
'error should be recognised by util.types.isNativeError'
|
||||
).to.be.true
|
||||
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
node_modules/
|
4
libraries/object-persistor/.gitignore
vendored
4
libraries/object-persistor/.gitignore
vendored
|
@ -1,4 +0,0 @@
|
|||
/node_modules
|
||||
*.swp
|
||||
|
||||
.npmrc
|
|
@ -1 +1 @@
|
|||
18.20.2
|
||||
22.17.0
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
object-persistor
|
||||
--dependencies=None
|
||||
--docker-repos=gcr.io/overleaf-ops
|
||||
--docker-repos=us-east1-docker.pkg.dev/overleaf-ops/ol-docker
|
||||
--env-add=
|
||||
--env-pass-through=
|
||||
--esmock-loader=False
|
||||
--is-library=True
|
||||
--node-version=18.20.2
|
||||
--node-version=22.17.0
|
||||
--public-repo=False
|
||||
--script-version=4.5.0
|
||||
--script-version=4.7.0
|
||||
|
|
|
@ -24,7 +24,8 @@
|
|||
"@overleaf/logger": "*",
|
||||
"@overleaf/metrics": "*",
|
||||
"@overleaf/o-error": "*",
|
||||
"aws-sdk": "^2.718.0",
|
||||
"@overleaf/stream-utils": "*",
|
||||
"aws-sdk": "^2.1691.0",
|
||||
"fast-crc32c": "overleaf/node-fast-crc32c#aae6b2a4c7a7a159395df9cc6c38dfde702d6f51",
|
||||
"glob": "^7.1.6",
|
||||
"range-parser": "^1.2.1",
|
||||
|
@ -33,9 +34,9 @@
|
|||
"devDependencies": {
|
||||
"chai": "^4.3.6",
|
||||
"chai-as-promised": "^7.1.1",
|
||||
"mocha": "^10.2.0",
|
||||
"mocha": "^11.1.0",
|
||||
"mock-fs": "^5.2.0",
|
||||
"mongodb": "^6.1.0",
|
||||
"mongodb": "6.12.0",
|
||||
"sandboxed-module": "^2.0.4",
|
||||
"sinon": "^9.2.4",
|
||||
"sinon-chai": "^3.7.0",
|
||||
|
|
|
@ -1,6 +1,12 @@
|
|||
const { NotImplementedError } = require('./Errors')
|
||||
|
||||
module.exports = class AbstractPersistor {
|
||||
/**
|
||||
* @param location
|
||||
* @param target
|
||||
* @param {string} source
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async sendFile(location, target, source) {
|
||||
throw new NotImplementedError('method not implemented in persistor', {
|
||||
method: 'sendFile',
|
||||
|
@ -10,6 +16,13 @@ module.exports = class AbstractPersistor {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param location
|
||||
* @param target
|
||||
* @param {NodeJS.ReadableStream} sourceStream
|
||||
* @param {Object} opts
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async sendStream(location, target, sourceStream, opts = {}) {
|
||||
throw new NotImplementedError('method not implemented in persistor', {
|
||||
method: 'sendStream',
|
||||
|
@ -22,12 +35,12 @@ module.exports = class AbstractPersistor {
|
|||
/**
|
||||
* @param location
|
||||
* @param name
|
||||
* @param {Object} opts
|
||||
* @param {Number} opts.start
|
||||
* @param {Number} opts.end
|
||||
* @return {Promise<Readable>}
|
||||
* @param {Object} [opts]
|
||||
* @param {Number} [opts.start]
|
||||
* @param {Number} [opts.end]
|
||||
* @return {Promise<NodeJS.ReadableStream>}
|
||||
*/
|
||||
async getObjectStream(location, name, opts) {
|
||||
async getObjectStream(location, name, opts = {}) {
|
||||
throw new NotImplementedError('method not implemented in persistor', {
|
||||
method: 'getObjectStream',
|
||||
location,
|
||||
|
@ -36,6 +49,11 @@ module.exports = class AbstractPersistor {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} location
|
||||
* @param {string} name
|
||||
* @return {Promise<string>}
|
||||
*/
|
||||
async getRedirectUrl(location, name) {
|
||||
throw new NotImplementedError('method not implemented in persistor', {
|
||||
method: 'getRedirectUrl',
|
||||
|
@ -44,7 +62,13 @@ module.exports = class AbstractPersistor {
|
|||
})
|
||||
}
|
||||
|
||||
async getObjectSize(location, name) {
|
||||
/**
|
||||
* @param {string} location
|
||||
* @param {string} name
|
||||
* @param {Object} opts
|
||||
* @return {Promise<number>}
|
||||
*/
|
||||
async getObjectSize(location, name, opts) {
|
||||
throw new NotImplementedError('method not implemented in persistor', {
|
||||
method: 'getObjectSize',
|
||||
location,
|
||||
|
@ -52,7 +76,13 @@ module.exports = class AbstractPersistor {
|
|||
})
|
||||
}
|
||||
|
||||
async getObjectMd5Hash(location, name) {
|
||||
/**
|
||||
* @param {string} location
|
||||
* @param {string} name
|
||||
* @param {Object} opts
|
||||
* @return {Promise<string>}
|
||||
*/
|
||||
async getObjectMd5Hash(location, name, opts) {
|
||||
throw new NotImplementedError('method not implemented in persistor', {
|
||||
method: 'getObjectMd5Hash',
|
||||
location,
|
||||
|
@ -60,7 +90,14 @@ module.exports = class AbstractPersistor {
|
|||
})
|
||||
}
|
||||
|
||||
async copyObject(location, fromName, toName) {
|
||||
/**
|
||||
* @param {string} location
|
||||
* @param {string} fromName
|
||||
* @param {string} toName
|
||||
* @param {Object} opts
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async copyObject(location, fromName, toName, opts) {
|
||||
throw new NotImplementedError('method not implemented in persistor', {
|
||||
method: 'copyObject',
|
||||
location,
|
||||
|
@ -69,6 +106,11 @@ module.exports = class AbstractPersistor {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} location
|
||||
* @param {string} name
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async deleteObject(location, name) {
|
||||
throw new NotImplementedError('method not implemented in persistor', {
|
||||
method: 'deleteObject',
|
||||
|
@ -77,7 +119,13 @@ module.exports = class AbstractPersistor {
|
|||
})
|
||||
}
|
||||
|
||||
async deleteDirectory(location, name) {
|
||||
/**
|
||||
* @param {string} location
|
||||
* @param {string} name
|
||||
* @param {string} [continuationToken]
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async deleteDirectory(location, name, continuationToken) {
|
||||
throw new NotImplementedError('method not implemented in persistor', {
|
||||
method: 'deleteDirectory',
|
||||
location,
|
||||
|
@ -85,7 +133,13 @@ module.exports = class AbstractPersistor {
|
|||
})
|
||||
}
|
||||
|
||||
async checkIfObjectExists(location, name) {
|
||||
/**
|
||||
* @param {string} location
|
||||
* @param {string} name
|
||||
* @param {Object} opts
|
||||
* @return {Promise<boolean>}
|
||||
*/
|
||||
async checkIfObjectExists(location, name, opts) {
|
||||
throw new NotImplementedError('method not implemented in persistor', {
|
||||
method: 'checkIfObjectExists',
|
||||
location,
|
||||
|
@ -93,7 +147,13 @@ module.exports = class AbstractPersistor {
|
|||
})
|
||||
}
|
||||
|
||||
async directorySize(location, name) {
|
||||
/**
|
||||
* @param {string} location
|
||||
* @param {string} name
|
||||
* @param {string} [continuationToken]
|
||||
* @return {Promise<number>}
|
||||
*/
|
||||
async directorySize(location, name, continuationToken) {
|
||||
throw new NotImplementedError('method not implemented in persistor', {
|
||||
method: 'directorySize',
|
||||
location,
|
||||
|
|
|
@ -5,6 +5,8 @@ class WriteError extends OError {}
|
|||
class ReadError extends OError {}
|
||||
class SettingsError extends OError {}
|
||||
class NotImplementedError extends OError {}
|
||||
class AlreadyWrittenError extends OError {}
|
||||
class NoKEKMatchedError extends OError {}
|
||||
|
||||
module.exports = {
|
||||
NotFoundError,
|
||||
|
@ -12,4 +14,6 @@ module.exports = {
|
|||
ReadError,
|
||||
SettingsError,
|
||||
NotImplementedError,
|
||||
AlreadyWrittenError,
|
||||
NoKEKMatchedError,
|
||||
}
|
||||
|
|
|
@ -1,20 +1,26 @@
|
|||
const crypto = require('crypto')
|
||||
const fs = require('fs')
|
||||
const fsPromises = require('fs/promises')
|
||||
const crypto = require('node:crypto')
|
||||
const fs = require('node:fs')
|
||||
const fsPromises = require('node:fs/promises')
|
||||
const globCallbacks = require('glob')
|
||||
const Path = require('path')
|
||||
const { PassThrough } = require('stream')
|
||||
const { pipeline } = require('stream/promises')
|
||||
const { promisify } = require('util')
|
||||
const Path = require('node:path')
|
||||
const { PassThrough } = require('node:stream')
|
||||
const { pipeline } = require('node:stream/promises')
|
||||
const { promisify } = require('node:util')
|
||||
|
||||
const AbstractPersistor = require('./AbstractPersistor')
|
||||
const { ReadError, WriteError } = require('./Errors')
|
||||
const { ReadError, WriteError, NotImplementedError } = require('./Errors')
|
||||
const PersistorHelper = require('./PersistorHelper')
|
||||
|
||||
const glob = promisify(globCallbacks)
|
||||
|
||||
module.exports = class FSPersistor extends AbstractPersistor {
|
||||
constructor(settings = {}) {
|
||||
if (settings.storageClass) {
|
||||
throw new NotImplementedError(
|
||||
'FS backend does not support storage classes'
|
||||
)
|
||||
}
|
||||
|
||||
super()
|
||||
this.useSubdirectories = Boolean(settings.useSubdirectories)
|
||||
}
|
||||
|
@ -36,6 +42,14 @@ module.exports = class FSPersistor extends AbstractPersistor {
|
|||
}
|
||||
|
||||
async sendStream(location, target, sourceStream, opts = {}) {
|
||||
if (opts.ifNoneMatch === '*') {
|
||||
// The standard library only has fs.rename(), which does not support exclusive flags.
|
||||
// Refuse to act on this write operation.
|
||||
throw new NotImplementedError(
|
||||
'Overwrite protection required by caller, but it is not available is FS backend. Configure GCS or S3 backend instead, get in touch with support for further information.'
|
||||
)
|
||||
}
|
||||
|
||||
const targetPath = this._getFsPath(location, target)
|
||||
|
||||
try {
|
||||
|
@ -55,7 +69,7 @@ module.exports = class FSPersistor extends AbstractPersistor {
|
|||
throw PersistorHelper.wrapError(
|
||||
err,
|
||||
'failed to write stream',
|
||||
{ location, target },
|
||||
{ location, target, ifNoneMatch: opts.ifNoneMatch },
|
||||
WriteError
|
||||
)
|
||||
}
|
||||
|
@ -63,6 +77,11 @@ module.exports = class FSPersistor extends AbstractPersistor {
|
|||
|
||||
// opts may be {start: Number, end: Number}
|
||||
async getObjectStream(location, name, opts = {}) {
|
||||
if (opts.autoGunzip) {
|
||||
throw new NotImplementedError(
|
||||
'opts.autoGunzip is not supported by FS backend. Configure GCS or S3 backend instead, get in touch with support for further information.'
|
||||
)
|
||||
}
|
||||
const observer = new PersistorHelper.ObserverStream({
|
||||
metric: 'fs.ingress', // ingress to us from disk
|
||||
bucket: location,
|
||||
|
@ -286,8 +305,10 @@ module.exports = class FSPersistor extends AbstractPersistor {
|
|||
|
||||
async _listDirectory(path) {
|
||||
if (this.useSubdirectories) {
|
||||
// eslint-disable-next-line @typescript-eslint/return-await
|
||||
return await glob(Path.join(path, '**'))
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/return-await
|
||||
return await glob(`${path}_*`)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,17 +1,28 @@
|
|||
const fs = require('fs')
|
||||
const { pipeline } = require('stream/promises')
|
||||
const { PassThrough } = require('stream')
|
||||
const fs = require('node:fs')
|
||||
const { pipeline } = require('node:stream/promises')
|
||||
const { PassThrough } = require('node:stream')
|
||||
const { Storage, IdempotencyStrategy } = require('@google-cloud/storage')
|
||||
const { WriteError, ReadError, NotFoundError } = require('./Errors')
|
||||
const {
|
||||
WriteError,
|
||||
ReadError,
|
||||
NotFoundError,
|
||||
NotImplementedError,
|
||||
} = require('./Errors')
|
||||
const asyncPool = require('tiny-async-pool')
|
||||
const AbstractPersistor = require('./AbstractPersistor')
|
||||
const PersistorHelper = require('./PersistorHelper')
|
||||
const Logger = require('@overleaf/logger')
|
||||
const zlib = require('node:zlib')
|
||||
|
||||
module.exports = class GcsPersistor extends AbstractPersistor {
|
||||
constructor(settings) {
|
||||
super()
|
||||
if (settings.storageClass) {
|
||||
throw new NotImplementedError(
|
||||
'Use default bucket class for GCS instead of settings.storageClass'
|
||||
)
|
||||
}
|
||||
|
||||
super()
|
||||
this.settings = settings
|
||||
|
||||
// endpoint settings will be null by default except for tests
|
||||
|
@ -78,10 +89,14 @@ module.exports = class GcsPersistor extends AbstractPersistor {
|
|||
writeOptions.metadata = writeOptions.metadata || {}
|
||||
writeOptions.metadata.contentEncoding = opts.contentEncoding
|
||||
}
|
||||
const fileOptions = {}
|
||||
if (opts.ifNoneMatch === '*') {
|
||||
fileOptions.generation = 0
|
||||
}
|
||||
|
||||
const uploadStream = this.storage
|
||||
.bucket(bucketName)
|
||||
.file(key)
|
||||
.file(key, fileOptions)
|
||||
.createWriteStream(writeOptions)
|
||||
|
||||
await pipeline(readStream, observer, uploadStream)
|
||||
|
@ -97,7 +112,7 @@ module.exports = class GcsPersistor extends AbstractPersistor {
|
|||
throw PersistorHelper.wrapError(
|
||||
err,
|
||||
'upload to GCS failed',
|
||||
{ bucketName, key },
|
||||
{ bucketName, key, ifNoneMatch: opts.ifNoneMatch },
|
||||
WriteError
|
||||
)
|
||||
}
|
||||
|
@ -113,12 +128,14 @@ module.exports = class GcsPersistor extends AbstractPersistor {
|
|||
.file(key)
|
||||
.createReadStream({ decompress: false, ...opts })
|
||||
|
||||
let contentEncoding
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
stream.on('response', res => {
|
||||
switch (res.statusCode) {
|
||||
case 200: // full response
|
||||
case 206: // partial response
|
||||
contentEncoding = res.headers['content-encoding']
|
||||
return resolve()
|
||||
case 404:
|
||||
return reject(new NotFoundError())
|
||||
|
@ -139,7 +156,11 @@ module.exports = class GcsPersistor extends AbstractPersistor {
|
|||
}
|
||||
// Return a PassThrough stream with a minimal interface. It will buffer until the caller starts reading. It will emit errors from the source stream (Stream.pipeline passes errors along).
|
||||
const pass = new PassThrough()
|
||||
pipeline(stream, observer, pass).catch(() => {})
|
||||
const transformer = []
|
||||
if (contentEncoding === 'gzip' && opts.autoGunzip) {
|
||||
transformer.push(zlib.createGunzip())
|
||||
}
|
||||
pipeline(stream, observer, ...transformer, pass).catch(() => {})
|
||||
return pass
|
||||
}
|
||||
|
||||
|
@ -176,7 +197,7 @@ module.exports = class GcsPersistor extends AbstractPersistor {
|
|||
.bucket(bucketName)
|
||||
.file(key)
|
||||
.getMetadata()
|
||||
return metadata.size
|
||||
return parseInt(metadata.size, 10)
|
||||
} catch (err) {
|
||||
throw PersistorHelper.wrapError(
|
||||
err,
|
||||
|
@ -285,7 +306,10 @@ module.exports = class GcsPersistor extends AbstractPersistor {
|
|||
)
|
||||
}
|
||||
|
||||
return files.reduce((acc, file) => Number(file.metadata.size) + acc, 0)
|
||||
return files.reduce(
|
||||
(acc, file) => parseInt(file.metadata.size, 10) + acc,
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
async checkIfObjectExists(bucketName, key) {
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
const AbstractPersistor = require('./AbstractPersistor')
|
||||
const Logger = require('@overleaf/logger')
|
||||
const Metrics = require('@overleaf/metrics')
|
||||
const Stream = require('stream')
|
||||
const { pipeline } = require('stream/promises')
|
||||
const Stream = require('node:stream')
|
||||
const { pipeline } = require('node:stream/promises')
|
||||
const { NotFoundError, WriteError } = require('./Errors')
|
||||
|
||||
// Persistor that wraps two other persistors. Talks to the 'primary' by default,
|
||||
|
|
483
libraries/object-persistor/src/PerProjectEncryptedS3Persistor.js
Normal file
483
libraries/object-persistor/src/PerProjectEncryptedS3Persistor.js
Normal file
|
@ -0,0 +1,483 @@
|
|||
// @ts-check
|
||||
const Crypto = require('node:crypto')
|
||||
const Stream = require('node:stream')
|
||||
const fs = require('node:fs')
|
||||
const { promisify } = require('node:util')
|
||||
const { WritableBuffer } = require('@overleaf/stream-utils')
|
||||
const { S3Persistor, SSECOptions } = require('./S3Persistor.js')
|
||||
const {
|
||||
AlreadyWrittenError,
|
||||
NoKEKMatchedError,
|
||||
NotFoundError,
|
||||
NotImplementedError,
|
||||
ReadError,
|
||||
} = require('./Errors')
|
||||
const logger = require('@overleaf/logger')
|
||||
const Path = require('node:path')
|
||||
|
||||
const generateKey = promisify(Crypto.generateKey)
|
||||
const hkdf = promisify(Crypto.hkdf)
|
||||
|
||||
const AES256_KEY_LENGTH = 32
|
||||
|
||||
/**
|
||||
* @typedef {import('aws-sdk').AWSError} AWSError
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} Settings
|
||||
* @property {boolean} automaticallyRotateDEKEncryption
|
||||
* @property {string} dataEncryptionKeyBucketName
|
||||
* @property {boolean} ignoreErrorsFromDEKReEncryption
|
||||
* @property {(bucketName: string, path: string) => string} pathToProjectFolder
|
||||
* @property {() => Promise<Array<RootKeyEncryptionKey>>} getRootKeyEncryptionKeys
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {import('./types').ListDirectoryResult} ListDirectoryResult
|
||||
*/
|
||||
|
||||
/**
|
||||
* Helper function to make TS happy when accessing error properties
|
||||
* AWSError is not an actual class, so we cannot use instanceof.
|
||||
* @param {any} err
|
||||
* @return {err is AWSError}
|
||||
*/
|
||||
function isAWSError(err) {
|
||||
return !!err
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} err
|
||||
* @return {boolean}
|
||||
*/
|
||||
function isForbiddenError(err) {
|
||||
if (!err || !(err instanceof ReadError || err instanceof NotFoundError)) {
|
||||
return false
|
||||
}
|
||||
const cause = err.cause
|
||||
if (!isAWSError(cause)) return false
|
||||
return cause.statusCode === 403
|
||||
}
|
||||
|
||||
class RootKeyEncryptionKey {
|
||||
/** @type {Buffer} */
|
||||
#keyEncryptionKey
|
||||
/** @type {Buffer} */
|
||||
#salt
|
||||
|
||||
/**
|
||||
* @param {Buffer} keyEncryptionKey
|
||||
* @param {Buffer} salt
|
||||
*/
|
||||
constructor(keyEncryptionKey, salt) {
|
||||
if (keyEncryptionKey.byteLength !== AES256_KEY_LENGTH) {
|
||||
throw new Error(`kek is not ${AES256_KEY_LENGTH} bytes long`)
|
||||
}
|
||||
this.#keyEncryptionKey = keyEncryptionKey
|
||||
this.#salt = salt
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} prefix
|
||||
* @return {Promise<SSECOptions>}
|
||||
*/
|
||||
async forProject(prefix) {
|
||||
return new SSECOptions(
|
||||
Buffer.from(
|
||||
await hkdf(
|
||||
'sha256',
|
||||
this.#keyEncryptionKey,
|
||||
this.#salt,
|
||||
prefix,
|
||||
AES256_KEY_LENGTH
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class PerProjectEncryptedS3Persistor extends S3Persistor {
|
||||
/** @type {Settings} */
|
||||
#settings
|
||||
/** @type {Promise<Array<RootKeyEncryptionKey>>} */
|
||||
#availableKeyEncryptionKeysPromise
|
||||
|
||||
/**
|
||||
* @param {Settings} settings
|
||||
*/
|
||||
constructor(settings) {
|
||||
if (!settings.dataEncryptionKeyBucketName) {
|
||||
throw new Error('settings.dataEncryptionKeyBucketName is missing')
|
||||
}
|
||||
super(settings)
|
||||
this.#settings = settings
|
||||
this.#availableKeyEncryptionKeysPromise = settings
|
||||
.getRootKeyEncryptionKeys()
|
||||
.then(rootKEKs => {
|
||||
if (rootKEKs.length === 0) throw new Error('no root kek provided')
|
||||
return rootKEKs
|
||||
})
|
||||
}
|
||||
|
||||
async ensureKeyEncryptionKeysLoaded() {
|
||||
await this.#availableKeyEncryptionKeysPromise
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} bucketName
|
||||
* @param {string} path
|
||||
* @return {{dekPath: string, projectFolder: string}}
|
||||
*/
|
||||
#buildProjectPaths(bucketName, path) {
|
||||
const projectFolder = this.#settings.pathToProjectFolder(bucketName, path)
|
||||
const dekPath = Path.join(projectFolder, 'dek')
|
||||
return { projectFolder, dekPath }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} projectFolder
|
||||
* @return {Promise<SSECOptions>}
|
||||
*/
|
||||
async #getCurrentKeyEncryptionKey(projectFolder) {
|
||||
const [currentRootKEK] = await this.#availableKeyEncryptionKeysPromise
|
||||
return await currentRootKEK.forProject(projectFolder)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} bucketName
|
||||
* @param {string} path
|
||||
*/
|
||||
async getDataEncryptionKeySize(bucketName, path) {
|
||||
const { projectFolder, dekPath } = this.#buildProjectPaths(bucketName, path)
|
||||
for (const rootKEK of await this.#availableKeyEncryptionKeysPromise) {
|
||||
const ssecOptions = await rootKEK.forProject(projectFolder)
|
||||
try {
|
||||
return await super.getObjectSize(
|
||||
this.#settings.dataEncryptionKeyBucketName,
|
||||
dekPath,
|
||||
{ ssecOptions }
|
||||
)
|
||||
} catch (err) {
|
||||
if (isForbiddenError(err)) continue
|
||||
throw err
|
||||
}
|
||||
}
|
||||
throw new NoKEKMatchedError('no kek matched')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} bucketName
|
||||
* @param {string} path
|
||||
* @return {Promise<CachedPerProjectEncryptedS3Persistor>}
|
||||
*/
|
||||
async forProject(bucketName, path) {
|
||||
return new CachedPerProjectEncryptedS3Persistor(
|
||||
this,
|
||||
await this.#getDataEncryptionKeyOptions(bucketName, path)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} bucketName
|
||||
* @param {string} path
|
||||
* @return {Promise<CachedPerProjectEncryptedS3Persistor>}
|
||||
*/
|
||||
async forProjectRO(bucketName, path) {
|
||||
return new CachedPerProjectEncryptedS3Persistor(
|
||||
this,
|
||||
await this.#getExistingDataEncryptionKeyOptions(bucketName, path)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} bucketName
|
||||
* @param {string} path
|
||||
* @return {Promise<CachedPerProjectEncryptedS3Persistor>}
|
||||
*/
|
||||
async generateDataEncryptionKey(bucketName, path) {
|
||||
return new CachedPerProjectEncryptedS3Persistor(
|
||||
this,
|
||||
await this.#generateDataEncryptionKeyOptions(bucketName, path)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} bucketName
|
||||
* @param {string} path
|
||||
* @return {Promise<SSECOptions>}
|
||||
*/
|
||||
async #generateDataEncryptionKeyOptions(bucketName, path) {
|
||||
const dataEncryptionKey = (
|
||||
await generateKey('aes', { length: 256 })
|
||||
).export()
|
||||
const { projectFolder, dekPath } = this.#buildProjectPaths(bucketName, path)
|
||||
await super.sendStream(
|
||||
this.#settings.dataEncryptionKeyBucketName,
|
||||
dekPath,
|
||||
Stream.Readable.from([dataEncryptionKey]),
|
||||
{
|
||||
// Do not overwrite any objects if already created
|
||||
ifNoneMatch: '*',
|
||||
ssecOptions: await this.#getCurrentKeyEncryptionKey(projectFolder),
|
||||
contentLength: 32,
|
||||
}
|
||||
)
|
||||
return new SSECOptions(dataEncryptionKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} bucketName
|
||||
* @param {string} path
|
||||
* @return {Promise<SSECOptions>}
|
||||
*/
|
||||
async #getExistingDataEncryptionKeyOptions(bucketName, path) {
|
||||
const { projectFolder, dekPath } = this.#buildProjectPaths(bucketName, path)
|
||||
let res
|
||||
let kekIndex = 0
|
||||
for (const rootKEK of await this.#availableKeyEncryptionKeysPromise) {
|
||||
const ssecOptions = await rootKEK.forProject(projectFolder)
|
||||
try {
|
||||
res = await super.getObjectStream(
|
||||
this.#settings.dataEncryptionKeyBucketName,
|
||||
dekPath,
|
||||
{ ssecOptions }
|
||||
)
|
||||
break
|
||||
} catch (err) {
|
||||
if (isForbiddenError(err)) {
|
||||
kekIndex++
|
||||
continue
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
if (!res) throw new NoKEKMatchedError('no kek matched')
|
||||
const buf = new WritableBuffer()
|
||||
await Stream.promises.pipeline(res, buf)
|
||||
|
||||
if (kekIndex !== 0 && this.#settings.automaticallyRotateDEKEncryption) {
|
||||
const ssecOptions = await this.#getCurrentKeyEncryptionKey(projectFolder)
|
||||
try {
|
||||
await super.sendStream(
|
||||
this.#settings.dataEncryptionKeyBucketName,
|
||||
dekPath,
|
||||
Stream.Readable.from([buf.getContents()]),
|
||||
{ ssecOptions }
|
||||
)
|
||||
} catch (err) {
|
||||
if (this.#settings.ignoreErrorsFromDEKReEncryption) {
|
||||
logger.warn({ err, dekPath }, 'failed to persist re-encrypted DEK')
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new SSECOptions(buf.getContents())
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} bucketName
|
||||
* @param {string} path
|
||||
* @return {Promise<SSECOptions>}
|
||||
*/
|
||||
async #getDataEncryptionKeyOptions(bucketName, path) {
|
||||
try {
|
||||
return await this.#getExistingDataEncryptionKeyOptions(bucketName, path)
|
||||
} catch (err) {
|
||||
if (err instanceof NotFoundError) {
|
||||
try {
|
||||
return await this.#generateDataEncryptionKeyOptions(bucketName, path)
|
||||
} catch (err2) {
|
||||
if (err2 instanceof AlreadyWrittenError) {
|
||||
// Concurrent initial write
|
||||
return await this.#getExistingDataEncryptionKeyOptions(
|
||||
bucketName,
|
||||
path
|
||||
)
|
||||
}
|
||||
throw err2
|
||||
}
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async sendStream(bucketName, path, sourceStream, opts = {}) {
|
||||
const ssecOptions =
|
||||
opts.ssecOptions ||
|
||||
(await this.#getDataEncryptionKeyOptions(bucketName, path))
|
||||
return await super.sendStream(bucketName, path, sourceStream, {
|
||||
...opts,
|
||||
ssecOptions,
|
||||
})
|
||||
}
|
||||
|
||||
async getObjectStream(bucketName, path, opts = {}) {
|
||||
const ssecOptions =
|
||||
opts.ssecOptions ||
|
||||
(await this.#getExistingDataEncryptionKeyOptions(bucketName, path))
|
||||
return await super.getObjectStream(bucketName, path, {
|
||||
...opts,
|
||||
ssecOptions,
|
||||
})
|
||||
}
|
||||
|
||||
async getObjectSize(bucketName, path, opts = {}) {
|
||||
const ssecOptions =
|
||||
opts.ssecOptions ||
|
||||
(await this.#getExistingDataEncryptionKeyOptions(bucketName, path))
|
||||
return await super.getObjectSize(bucketName, path, { ...opts, ssecOptions })
|
||||
}
|
||||
|
||||
async getObjectStorageClass(bucketName, path, opts = {}) {
|
||||
const ssecOptions =
|
||||
opts.ssecOptions ||
|
||||
(await this.#getExistingDataEncryptionKeyOptions(bucketName, path))
|
||||
return await super.getObjectStorageClass(bucketName, path, {
|
||||
...opts,
|
||||
ssecOptions,
|
||||
})
|
||||
}
|
||||
|
||||
async directorySize(bucketName, path, continuationToken) {
|
||||
// Note: Listing a bucket does not require SSE-C credentials.
|
||||
return await super.directorySize(bucketName, path, continuationToken)
|
||||
}
|
||||
|
||||
async deleteDirectory(bucketName, path, continuationToken) {
|
||||
// Let [Settings.pathToProjectFolder] validate the project path before deleting things.
|
||||
const { projectFolder, dekPath } = this.#buildProjectPaths(bucketName, path)
|
||||
// Note: Listing/Deleting a prefix does not require SSE-C credentials.
|
||||
await super.deleteDirectory(bucketName, path, continuationToken)
|
||||
if (projectFolder === path) {
|
||||
await super.deleteObject(
|
||||
this.#settings.dataEncryptionKeyBucketName,
|
||||
dekPath
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async getObjectMd5Hash(bucketName, path, opts = {}) {
|
||||
// The ETag in object metadata is not the MD5 content hash, skip the HEAD request.
|
||||
opts = { ...opts, etagIsNotMD5: true }
|
||||
return await super.getObjectMd5Hash(bucketName, path, opts)
|
||||
}
|
||||
|
||||
async copyObject(bucketName, sourcePath, destinationPath, opts = {}) {
|
||||
const ssecOptions =
|
||||
opts.ssecOptions ||
|
||||
(await this.#getDataEncryptionKeyOptions(bucketName, destinationPath))
|
||||
const ssecSrcOptions =
|
||||
opts.ssecSrcOptions ||
|
||||
(await this.#getExistingDataEncryptionKeyOptions(bucketName, sourcePath))
|
||||
return await super.copyObject(bucketName, sourcePath, destinationPath, {
|
||||
...opts,
|
||||
ssecOptions,
|
||||
ssecSrcOptions,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} bucketName
|
||||
* @param {string} path
|
||||
* @return {Promise<string>}
|
||||
*/
|
||||
async getRedirectUrl(bucketName, path) {
|
||||
throw new NotImplementedError('signed links are not supported with SSE-C')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper class for batch updates to avoid repeated fetching of the project path.
|
||||
*
|
||||
* A general "cache" for project keys is another alternative. For now, use a helper class.
|
||||
*/
|
||||
class CachedPerProjectEncryptedS3Persistor {
|
||||
/** @type SSECOptions */
|
||||
#projectKeyOptions
|
||||
/** @type PerProjectEncryptedS3Persistor */
|
||||
#parent
|
||||
|
||||
/**
|
||||
* @param {PerProjectEncryptedS3Persistor} parent
|
||||
* @param {SSECOptions} projectKeyOptions
|
||||
*/
|
||||
constructor(parent, projectKeyOptions) {
|
||||
this.#parent = parent
|
||||
this.#projectKeyOptions = projectKeyOptions
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} bucketName
|
||||
* @param {string} path
|
||||
* @param {string} fsPath
|
||||
*/
|
||||
async sendFile(bucketName, path, fsPath) {
|
||||
return await this.sendStream(bucketName, path, fs.createReadStream(fsPath))
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} bucketName
|
||||
* @param {string} path
|
||||
* @return {Promise<number>}
|
||||
*/
|
||||
async getObjectSize(bucketName, path) {
|
||||
return await this.#parent.getObjectSize(bucketName, path)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} bucketName
|
||||
* @param {string} path
|
||||
* @return {Promise<ListDirectoryResult>}
|
||||
*/
|
||||
async listDirectory(bucketName, path) {
|
||||
return await this.#parent.listDirectory(bucketName, path)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} bucketName
|
||||
* @param {string} path
|
||||
* @param {NodeJS.ReadableStream} sourceStream
|
||||
* @param {Object} opts
|
||||
* @param {string} [opts.contentType]
|
||||
* @param {string} [opts.contentEncoding]
|
||||
* @param {number} [opts.contentLength]
|
||||
* @param {'*'} [opts.ifNoneMatch]
|
||||
* @param {SSECOptions} [opts.ssecOptions]
|
||||
* @param {string} [opts.sourceMd5]
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async sendStream(bucketName, path, sourceStream, opts = {}) {
|
||||
return await this.#parent.sendStream(bucketName, path, sourceStream, {
|
||||
...opts,
|
||||
ssecOptions: this.#projectKeyOptions,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} bucketName
|
||||
* @param {string} path
|
||||
* @param {Object} opts
|
||||
* @param {number} [opts.start]
|
||||
* @param {number} [opts.end]
|
||||
* @param {boolean} [opts.autoGunzip]
|
||||
* @param {SSECOptions} [opts.ssecOptions]
|
||||
* @return {Promise<NodeJS.ReadableStream>}
|
||||
*/
|
||||
async getObjectStream(bucketName, path, opts = {}) {
|
||||
return await this.#parent.getObjectStream(bucketName, path, {
|
||||
...opts,
|
||||
ssecOptions: this.#projectKeyOptions,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PerProjectEncryptedS3Persistor,
|
||||
CachedPerProjectEncryptedS3Persistor,
|
||||
RootKeyEncryptionKey,
|
||||
}
|
|
@ -1,15 +1,20 @@
|
|||
const Logger = require('@overleaf/logger')
|
||||
const { SettingsError } = require('./Errors')
|
||||
const GcsPersistor = require('./GcsPersistor')
|
||||
const S3Persistor = require('./S3Persistor')
|
||||
const { S3Persistor } = require('./S3Persistor')
|
||||
const FSPersistor = require('./FSPersistor')
|
||||
const MigrationPersistor = require('./MigrationPersistor')
|
||||
const {
|
||||
PerProjectEncryptedS3Persistor,
|
||||
} = require('./PerProjectEncryptedS3Persistor')
|
||||
|
||||
function getPersistor(backend, settings) {
|
||||
switch (backend) {
|
||||
case 'aws-sdk':
|
||||
case 's3':
|
||||
return new S3Persistor(settings.s3)
|
||||
case 's3SSEC':
|
||||
return new PerProjectEncryptedS3Persistor(settings.s3SSEC)
|
||||
case 'fs':
|
||||
return new FSPersistor({
|
||||
useSubdirectories: settings.useSubdirectories,
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
const Crypto = require('crypto')
|
||||
const Stream = require('stream')
|
||||
const { pipeline } = require('stream/promises')
|
||||
const Crypto = require('node:crypto')
|
||||
const Stream = require('node:stream')
|
||||
const { pipeline } = require('node:stream/promises')
|
||||
const Logger = require('@overleaf/logger')
|
||||
const Metrics = require('@overleaf/metrics')
|
||||
const { WriteError, NotFoundError } = require('./Errors')
|
||||
const { WriteError, NotFoundError, AlreadyWrittenError } = require('./Errors')
|
||||
|
||||
const _128KiB = 128 * 1024
|
||||
const TIMING_BUCKETS = [
|
||||
|
@ -26,12 +26,14 @@ const SIZE_BUCKETS = [
|
|||
*/
|
||||
class ObserverStream extends Stream.Transform {
|
||||
/**
|
||||
* @param {string} metric prefix for metrics
|
||||
* @param {string} bucket name of source/target bucket
|
||||
* @param {string} hash optional hash algorithm, e.g. 'md5'
|
||||
* @param {Object} opts
|
||||
* @param {string} opts.metric prefix for metrics
|
||||
* @param {string} opts.bucket name of source/target bucket
|
||||
* @param {string} [opts.hash] optional hash algorithm, e.g. 'md5'
|
||||
*/
|
||||
constructor({ metric, bucket, hash = '' }) {
|
||||
constructor(opts) {
|
||||
super({ autoDestroy: true })
|
||||
const { metric, bucket, hash = '' } = opts
|
||||
|
||||
this.bytes = 0
|
||||
this.start = performance.now()
|
||||
|
@ -138,6 +140,10 @@ async function verifyMd5(persistor, bucket, key, sourceMd5, destMd5 = null) {
|
|||
}
|
||||
|
||||
function wrapError(error, message, params, ErrorType) {
|
||||
params = {
|
||||
...params,
|
||||
cause: error,
|
||||
}
|
||||
if (
|
||||
error instanceof NotFoundError ||
|
||||
['NoSuchKey', 'NotFound', 404, 'AccessDenied', 'ENOENT'].includes(
|
||||
|
@ -146,6 +152,13 @@ function wrapError(error, message, params, ErrorType) {
|
|||
(error.response && error.response.statusCode === 404)
|
||||
) {
|
||||
return new NotFoundError('no such file', params, error)
|
||||
} else if (
|
||||
params.ifNoneMatch === '*' &&
|
||||
(error.code === 'PreconditionFailed' ||
|
||||
error.response?.statusCode === 412 ||
|
||||
error instanceof AlreadyWrittenError)
|
||||
) {
|
||||
return new AlreadyWrittenError(message, params, error)
|
||||
} else {
|
||||
return new ErrorType(message, params, error)
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
const http = require('http')
|
||||
const https = require('https')
|
||||
// @ts-check
|
||||
const http = require('node:http')
|
||||
const https = require('node:https')
|
||||
if (http.globalAgent.maxSockets < 300) {
|
||||
http.globalAgent.maxSockets = 300
|
||||
}
|
||||
|
@ -7,27 +8,104 @@ if (https.globalAgent.maxSockets < 300) {
|
|||
https.globalAgent.maxSockets = 300
|
||||
}
|
||||
|
||||
const Crypto = require('node:crypto')
|
||||
const Metrics = require('@overleaf/metrics')
|
||||
const AbstractPersistor = require('./AbstractPersistor')
|
||||
const PersistorHelper = require('./PersistorHelper')
|
||||
|
||||
const { pipeline, PassThrough } = require('stream')
|
||||
const fs = require('fs')
|
||||
const { pipeline, PassThrough } = require('node:stream')
|
||||
const fs = require('node:fs')
|
||||
const S3 = require('aws-sdk/clients/s3')
|
||||
const { URL } = require('url')
|
||||
const { URL } = require('node:url')
|
||||
const { WriteError, ReadError, NotFoundError } = require('./Errors')
|
||||
const zlib = require('node:zlib')
|
||||
|
||||
/**
|
||||
* @typedef {import('aws-sdk/clients/s3').ListObjectsV2Output} ListObjectsV2Output
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {import('aws-sdk/clients/s3').Object} S3Object
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {import('./types').ListDirectoryResult} ListDirectoryResult
|
||||
*/
|
||||
|
||||
/**
|
||||
* Wrapper with private fields to avoid revealing them on console, JSON.stringify or similar.
|
||||
*/
|
||||
class SSECOptions {
|
||||
#keyAsBuffer
|
||||
#keyMD5
|
||||
|
||||
/**
|
||||
* @param {Buffer} keyAsBuffer
|
||||
*/
|
||||
constructor(keyAsBuffer) {
|
||||
this.#keyAsBuffer = keyAsBuffer
|
||||
this.#keyMD5 = Crypto.createHash('md5').update(keyAsBuffer).digest('base64')
|
||||
}
|
||||
|
||||
getPutOptions() {
|
||||
return {
|
||||
SSECustomerKey: this.#keyAsBuffer,
|
||||
SSECustomerKeyMD5: this.#keyMD5,
|
||||
SSECustomerAlgorithm: 'AES256',
|
||||
}
|
||||
}
|
||||
|
||||
getGetOptions() {
|
||||
return {
|
||||
SSECustomerKey: this.#keyAsBuffer,
|
||||
SSECustomerKeyMD5: this.#keyMD5,
|
||||
SSECustomerAlgorithm: 'AES256',
|
||||
}
|
||||
}
|
||||
|
||||
getCopyOptions() {
|
||||
return {
|
||||
CopySourceSSECustomerKey: this.#keyAsBuffer,
|
||||
CopySourceSSECustomerKeyMD5: this.#keyMD5,
|
||||
CopySourceSSECustomerAlgorithm: 'AES256',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class S3Persistor extends AbstractPersistor {
|
||||
/** @type {Map<string, S3>} */
|
||||
#clients = new Map()
|
||||
|
||||
module.exports = class S3Persistor extends AbstractPersistor {
|
||||
constructor(settings = {}) {
|
||||
super()
|
||||
|
||||
settings.storageClass = settings.storageClass || {}
|
||||
this.settings = settings
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} bucketName
|
||||
* @param {string} key
|
||||
* @param {string} fsPath
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async sendFile(bucketName, key, fsPath) {
|
||||
return await this.sendStream(bucketName, key, fs.createReadStream(fsPath))
|
||||
await this.sendStream(bucketName, key, fs.createReadStream(fsPath))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} bucketName
|
||||
* @param {string} key
|
||||
* @param {NodeJS.ReadableStream} readStream
|
||||
* @param {Object} opts
|
||||
* @param {string} [opts.contentType]
|
||||
* @param {string} [opts.contentEncoding]
|
||||
* @param {number} [opts.contentLength]
|
||||
* @param {'*'} [opts.ifNoneMatch]
|
||||
* @param {SSECOptions} [opts.ssecOptions]
|
||||
* @param {string} [opts.sourceMd5]
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async sendStream(bucketName, key, readStream, opts = {}) {
|
||||
try {
|
||||
const observeOptions = {
|
||||
|
@ -39,42 +117,71 @@ module.exports = class S3Persistor extends AbstractPersistor {
|
|||
// observer will catch errors, clean up and log a warning
|
||||
pipeline(readStream, observer, () => {})
|
||||
|
||||
// if we have an md5 hash, pass this to S3 to verify the upload
|
||||
/** @type {S3.PutObjectRequest} */
|
||||
const uploadOptions = {
|
||||
Bucket: bucketName,
|
||||
Key: key,
|
||||
Body: observer,
|
||||
}
|
||||
|
||||
if (this.settings.storageClass[bucketName]) {
|
||||
uploadOptions.StorageClass = this.settings.storageClass[bucketName]
|
||||
}
|
||||
|
||||
if (opts.contentType) {
|
||||
uploadOptions.ContentType = opts.contentType
|
||||
}
|
||||
if (opts.contentEncoding) {
|
||||
uploadOptions.ContentEncoding = opts.contentEncoding
|
||||
}
|
||||
if (opts.contentLength) {
|
||||
uploadOptions.ContentLength = opts.contentLength
|
||||
}
|
||||
if (opts.ifNoneMatch === '*') {
|
||||
uploadOptions.IfNoneMatch = '*'
|
||||
}
|
||||
if (opts.ssecOptions) {
|
||||
Object.assign(uploadOptions, opts.ssecOptions.getPutOptions())
|
||||
}
|
||||
|
||||
// if we have an md5 hash, pass this to S3 to verify the upload - otherwise
|
||||
// we rely on the S3 client's checksum calculation to validate the upload
|
||||
const clientOptions = {}
|
||||
let computeChecksums = false
|
||||
if (opts.sourceMd5) {
|
||||
uploadOptions.ContentMD5 = PersistorHelper.hexToBase64(opts.sourceMd5)
|
||||
} else {
|
||||
clientOptions.computeChecksums = true
|
||||
computeChecksums = true
|
||||
}
|
||||
|
||||
await this._getClientForBucket(bucketName, clientOptions)
|
||||
.upload(uploadOptions, { partSize: this.settings.partSize })
|
||||
.promise()
|
||||
if (this.settings.disableMultiPartUpload) {
|
||||
await this._getClientForBucket(bucketName, computeChecksums)
|
||||
.putObject(uploadOptions)
|
||||
.promise()
|
||||
} else {
|
||||
await this._getClientForBucket(bucketName, computeChecksums)
|
||||
.upload(uploadOptions, { partSize: this.settings.partSize })
|
||||
.promise()
|
||||
}
|
||||
} catch (err) {
|
||||
throw PersistorHelper.wrapError(
|
||||
err,
|
||||
'upload to S3 failed',
|
||||
{ bucketName, key },
|
||||
{ bucketName, key, ifNoneMatch: opts.ifNoneMatch },
|
||||
WriteError
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} bucketName
|
||||
* @param {string} key
|
||||
* @param {Object} [opts]
|
||||
* @param {number} [opts.start]
|
||||
* @param {number} [opts.end]
|
||||
* @param {boolean} [opts.autoGunzip]
|
||||
* @param {SSECOptions} [opts.ssecOptions]
|
||||
* @return {Promise<NodeJS.ReadableStream>}
|
||||
*/
|
||||
async getObjectStream(bucketName, key, opts) {
|
||||
opts = opts || {}
|
||||
|
||||
|
@ -85,6 +192,9 @@ module.exports = class S3Persistor extends AbstractPersistor {
|
|||
if (opts.start != null && opts.end != null) {
|
||||
params.Range = `bytes=${opts.start}-${opts.end}`
|
||||
}
|
||||
if (opts.ssecOptions) {
|
||||
Object.assign(params, opts.ssecOptions.getGetOptions())
|
||||
}
|
||||
const observer = new PersistorHelper.ObserverStream({
|
||||
metric: 's3.ingress', // ingress from S3 to us
|
||||
bucket: bucketName,
|
||||
|
@ -93,18 +203,21 @@ module.exports = class S3Persistor extends AbstractPersistor {
|
|||
const req = this._getClientForBucket(bucketName).getObject(params)
|
||||
const stream = req.createReadStream()
|
||||
|
||||
let contentEncoding
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
req.on('httpHeaders', statusCode => {
|
||||
req.on('httpHeaders', (statusCode, headers) => {
|
||||
switch (statusCode) {
|
||||
case 200: // full response
|
||||
case 206: // partial response
|
||||
return resolve()
|
||||
case 403: // AccessDenied is handled the same as NoSuchKey
|
||||
contentEncoding = headers['content-encoding']
|
||||
return resolve(undefined)
|
||||
case 403: // AccessDenied
|
||||
return // handled by stream.on('error') handler below
|
||||
case 404: // NoSuchKey
|
||||
return reject(new NotFoundError())
|
||||
return reject(new NotFoundError('not found'))
|
||||
default:
|
||||
return reject(new Error('non success status: ' + statusCode))
|
||||
// handled by stream.on('error') handler below
|
||||
}
|
||||
})
|
||||
// The AWS SDK is forwarding any errors from the request to the stream.
|
||||
|
@ -122,23 +235,32 @@ module.exports = class S3Persistor extends AbstractPersistor {
|
|||
}
|
||||
// Return a PassThrough stream with a minimal interface. It will buffer until the caller starts reading. It will emit errors from the source stream (Stream.pipeline passes errors along).
|
||||
const pass = new PassThrough()
|
||||
pipeline(stream, observer, pass, err => {
|
||||
const transformer = []
|
||||
if (contentEncoding === 'gzip' && opts.autoGunzip) {
|
||||
transformer.push(zlib.createGunzip())
|
||||
}
|
||||
pipeline(stream, observer, ...transformer, pass, err => {
|
||||
if (err) req.abort()
|
||||
})
|
||||
return pass
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} bucketName
|
||||
* @param {string} key
|
||||
* @return {Promise<string>}
|
||||
*/
|
||||
async getRedirectUrl(bucketName, key) {
|
||||
const expiresSeconds = Math.round(this.settings.signedUrlExpiryInMs / 1000)
|
||||
try {
|
||||
const url = await this._getClientForBucket(
|
||||
bucketName
|
||||
).getSignedUrlPromise('getObject', {
|
||||
Bucket: bucketName,
|
||||
Key: key,
|
||||
Expires: expiresSeconds,
|
||||
})
|
||||
return url
|
||||
return await this._getClientForBucket(bucketName).getSignedUrlPromise(
|
||||
'getObject',
|
||||
{
|
||||
Bucket: bucketName,
|
||||
Key: key,
|
||||
Expires: expiresSeconds,
|
||||
}
|
||||
)
|
||||
} catch (err) {
|
||||
throw PersistorHelper.wrapError(
|
||||
err,
|
||||
|
@ -149,28 +271,20 @@ module.exports = class S3Persistor extends AbstractPersistor {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} bucketName
|
||||
* @param {string} key
|
||||
* @param {string} [continuationToken]
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async deleteDirectory(bucketName, key, continuationToken) {
|
||||
let response
|
||||
const options = { Bucket: bucketName, Prefix: key }
|
||||
if (continuationToken) {
|
||||
options.ContinuationToken = continuationToken
|
||||
}
|
||||
|
||||
try {
|
||||
response = await this._getClientForBucket(bucketName)
|
||||
.listObjectsV2(options)
|
||||
.promise()
|
||||
} catch (err) {
|
||||
throw PersistorHelper.wrapError(
|
||||
err,
|
||||
'failed to list objects in S3',
|
||||
{ bucketName, key },
|
||||
ReadError
|
||||
)
|
||||
}
|
||||
|
||||
const objects = response.Contents.map(item => ({ Key: item.Key }))
|
||||
if (objects.length) {
|
||||
const { contents, response } = await this.listDirectory(
|
||||
bucketName,
|
||||
key,
|
||||
continuationToken
|
||||
)
|
||||
const objects = contents.map(item => ({ Key: item.Key || '' }))
|
||||
if (objects?.length) {
|
||||
try {
|
||||
await this._getClientForBucket(bucketName)
|
||||
.deleteObjects({
|
||||
|
@ -200,12 +314,52 @@ module.exports = class S3Persistor extends AbstractPersistor {
|
|||
}
|
||||
}
|
||||
|
||||
async getObjectSize(bucketName, key) {
|
||||
/**
|
||||
*
|
||||
* @param {string} bucketName
|
||||
* @param {string} key
|
||||
* @param {string} [continuationToken]
|
||||
* @return {Promise<ListDirectoryResult>}
|
||||
*/
|
||||
async listDirectory(bucketName, key, continuationToken) {
|
||||
let response
|
||||
const options = { Bucket: bucketName, Prefix: key }
|
||||
if (continuationToken) {
|
||||
options.ContinuationToken = continuationToken
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this._getClientForBucket(bucketName)
|
||||
.headObject({ Bucket: bucketName, Key: key })
|
||||
response = await this._getClientForBucket(bucketName)
|
||||
.listObjectsV2(options)
|
||||
.promise()
|
||||
} catch (err) {
|
||||
throw PersistorHelper.wrapError(
|
||||
err,
|
||||
'failed to list objects in S3',
|
||||
{ bucketName, key },
|
||||
ReadError
|
||||
)
|
||||
}
|
||||
|
||||
return { contents: response.Contents ?? [], response }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} bucketName
|
||||
* @param {string} key
|
||||
* @param {Object} opts
|
||||
* @param {SSECOptions} [opts.ssecOptions]
|
||||
* @return {Promise<S3.HeadObjectOutput>}
|
||||
*/
|
||||
async #headObject(bucketName, key, opts = {}) {
|
||||
const params = { Bucket: bucketName, Key: key }
|
||||
if (opts.ssecOptions) {
|
||||
Object.assign(params, opts.ssecOptions.getGetOptions())
|
||||
}
|
||||
try {
|
||||
return await this._getClientForBucket(bucketName)
|
||||
.headObject(params)
|
||||
.promise()
|
||||
return response.ContentLength
|
||||
} catch (err) {
|
||||
throw PersistorHelper.wrapError(
|
||||
err,
|
||||
|
@ -216,19 +370,51 @@ module.exports = class S3Persistor extends AbstractPersistor {
|
|||
}
|
||||
}
|
||||
|
||||
async getObjectMd5Hash(bucketName, key) {
|
||||
/**
|
||||
* @param {string} bucketName
|
||||
* @param {string} key
|
||||
* @param {Object} opts
|
||||
* @param {SSECOptions} [opts.ssecOptions]
|
||||
* @return {Promise<number>}
|
||||
*/
|
||||
async getObjectSize(bucketName, key, opts = {}) {
|
||||
const response = await this.#headObject(bucketName, key, opts)
|
||||
return response.ContentLength || 0
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} bucketName
|
||||
* @param {string} key
|
||||
* @param {Object} opts
|
||||
* @param {SSECOptions} [opts.ssecOptions]
|
||||
* @return {Promise<string | undefined>}
|
||||
*/
|
||||
async getObjectStorageClass(bucketName, key, opts = {}) {
|
||||
const response = await this.#headObject(bucketName, key, opts)
|
||||
return response.StorageClass
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} bucketName
|
||||
* @param {string} key
|
||||
* @param {Object} opts
|
||||
* @param {SSECOptions} [opts.ssecOptions]
|
||||
* @param {boolean} [opts.etagIsNotMD5]
|
||||
* @return {Promise<string>}
|
||||
*/
|
||||
async getObjectMd5Hash(bucketName, key, opts = {}) {
|
||||
try {
|
||||
const response = await this._getClientForBucket(bucketName)
|
||||
.headObject({ Bucket: bucketName, Key: key })
|
||||
.promise()
|
||||
const md5 = S3Persistor._md5FromResponse(response)
|
||||
if (md5) {
|
||||
return md5
|
||||
if (!opts.etagIsNotMD5) {
|
||||
const response = await this.#headObject(bucketName, key, opts)
|
||||
const md5 = S3Persistor._md5FromResponse(response)
|
||||
if (md5) {
|
||||
return md5
|
||||
}
|
||||
}
|
||||
// etag is not in md5 format
|
||||
Metrics.inc('s3.md5Download')
|
||||
return await PersistorHelper.calculateStreamMd5(
|
||||
await this.getObjectStream(bucketName, key)
|
||||
await this.getObjectStream(bucketName, key, opts)
|
||||
)
|
||||
} catch (err) {
|
||||
throw PersistorHelper.wrapError(
|
||||
|
@ -240,6 +426,11 @@ module.exports = class S3Persistor extends AbstractPersistor {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} bucketName
|
||||
* @param {string} key
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async deleteObject(bucketName, key) {
|
||||
try {
|
||||
await this._getClientForBucket(bucketName)
|
||||
|
@ -256,12 +447,27 @@ module.exports = class S3Persistor extends AbstractPersistor {
|
|||
}
|
||||
}
|
||||
|
||||
async copyObject(bucketName, sourceKey, destKey) {
|
||||
/**
|
||||
* @param {string} bucketName
|
||||
* @param {string} sourceKey
|
||||
* @param {string} destKey
|
||||
* @param {Object} opts
|
||||
* @param {SSECOptions} [opts.ssecSrcOptions]
|
||||
* @param {SSECOptions} [opts.ssecOptions]
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async copyObject(bucketName, sourceKey, destKey, opts = {}) {
|
||||
const params = {
|
||||
Bucket: bucketName,
|
||||
Key: destKey,
|
||||
CopySource: `${bucketName}/${sourceKey}`,
|
||||
}
|
||||
if (opts.ssecSrcOptions) {
|
||||
Object.assign(params, opts.ssecSrcOptions.getCopyOptions())
|
||||
}
|
||||
if (opts.ssecOptions) {
|
||||
Object.assign(params, opts.ssecOptions.getPutOptions())
|
||||
}
|
||||
try {
|
||||
await this._getClientForBucket(bucketName).copyObject(params).promise()
|
||||
} catch (err) {
|
||||
|
@ -274,9 +480,16 @@ module.exports = class S3Persistor extends AbstractPersistor {
|
|||
}
|
||||
}
|
||||
|
||||
async checkIfObjectExists(bucketName, key) {
|
||||
/**
|
||||
* @param {string} bucketName
|
||||
* @param {string} key
|
||||
* @param {Object} opts
|
||||
* @param {SSECOptions} [opts.ssecOptions]
|
||||
* @return {Promise<boolean>}
|
||||
*/
|
||||
async checkIfObjectExists(bucketName, key, opts) {
|
||||
try {
|
||||
await this.getObjectSize(bucketName, key)
|
||||
await this.getObjectSize(bucketName, key, opts)
|
||||
return true
|
||||
} catch (err) {
|
||||
if (err instanceof NotFoundError) {
|
||||
|
@ -291,6 +504,12 @@ module.exports = class S3Persistor extends AbstractPersistor {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} bucketName
|
||||
* @param {string} key
|
||||
* @param {string} [continuationToken]
|
||||
* @return {Promise<number>}
|
||||
*/
|
||||
async directorySize(bucketName, key, continuationToken) {
|
||||
try {
|
||||
const options = {
|
||||
|
@ -304,7 +523,8 @@ module.exports = class S3Persistor extends AbstractPersistor {
|
|||
.listObjectsV2(options)
|
||||
.promise()
|
||||
|
||||
const size = response.Contents.reduce((acc, item) => item.Size + acc, 0)
|
||||
const size =
|
||||
response.Contents?.reduce((acc, item) => (item.Size || 0) + acc, 0) || 0
|
||||
if (response.IsTruncated) {
|
||||
return (
|
||||
size +
|
||||
|
@ -326,15 +546,38 @@ module.exports = class S3Persistor extends AbstractPersistor {
|
|||
}
|
||||
}
|
||||
|
||||
_getClientForBucket(bucket, clientOptions) {
|
||||
return new S3(
|
||||
this._buildClientOptions(
|
||||
this.settings.bucketCreds?.[bucket],
|
||||
clientOptions
|
||||
/**
|
||||
* @param {string} bucket
|
||||
* @param {boolean} computeChecksums
|
||||
* @return {S3}
|
||||
* @private
|
||||
*/
|
||||
_getClientForBucket(bucket, computeChecksums = false) {
|
||||
/** @type {S3.Types.ClientConfiguration} */
|
||||
const clientOptions = {}
|
||||
const cacheKey = `${bucket}:${computeChecksums}`
|
||||
if (computeChecksums) {
|
||||
clientOptions.computeChecksums = true
|
||||
}
|
||||
let client = this.#clients.get(cacheKey)
|
||||
if (!client) {
|
||||
client = new S3(
|
||||
this._buildClientOptions(
|
||||
this.settings.bucketCreds?.[bucket],
|
||||
clientOptions
|
||||
)
|
||||
)
|
||||
)
|
||||
this.#clients.set(cacheKey, client)
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} bucketCredentials
|
||||
* @param {S3.Types.ClientConfiguration} clientOptions
|
||||
* @return {S3.Types.ClientConfiguration}
|
||||
* @private
|
||||
*/
|
||||
_buildClientOptions(bucketCredentials, clientOptions) {
|
||||
const options = clientOptions || {}
|
||||
|
||||
|
@ -356,7 +599,7 @@ module.exports = class S3Persistor extends AbstractPersistor {
|
|||
if (this.settings.endpoint) {
|
||||
const endpoint = new URL(this.settings.endpoint)
|
||||
options.endpoint = this.settings.endpoint
|
||||
options.sslEnabled = endpoint.protocol === 'https'
|
||||
options.sslEnabled = endpoint.protocol === 'https:'
|
||||
}
|
||||
|
||||
// path-style access is only used for acceptance tests
|
||||
|
@ -370,9 +613,22 @@ module.exports = class S3Persistor extends AbstractPersistor {
|
|||
}
|
||||
}
|
||||
|
||||
if (options.sslEnabled && this.settings.ca && !options.httpOptions?.agent) {
|
||||
options.httpOptions = options.httpOptions || {}
|
||||
options.httpOptions.agent = new https.Agent({
|
||||
rejectUnauthorized: true,
|
||||
ca: this.settings.ca,
|
||||
})
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {S3.HeadObjectOutput} response
|
||||
* @return {string|null}
|
||||
* @private
|
||||
*/
|
||||
static _md5FromResponse(response) {
|
||||
const md5 = (response.ETag || '').replace(/[ "]/g, '')
|
||||
if (!md5.match(/^[a-f0-9]{32}$/)) {
|
||||
|
@ -382,3 +638,8 @@ module.exports = class S3Persistor extends AbstractPersistor {
|
|||
return md5
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
S3Persistor,
|
||||
SSECOptions,
|
||||
}
|
||||
|
|
6
libraries/object-persistor/src/types.d.ts
vendored
Normal file
6
libraries/object-persistor/src/types.d.ts
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
import type { ListObjectsV2Output, Object } from 'aws-sdk/clients/s3'
|
||||
|
||||
export type ListDirectoryResult = {
|
||||
contents: Array<Object>
|
||||
response: ListObjectsV2Output
|
||||
}
|
|
@ -25,4 +25,9 @@ SandboxedModule.configure({
|
|||
},
|
||||
},
|
||||
globals: { Buffer, Math, console, process, URL },
|
||||
sourceTransformers: {
|
||||
removeNodePrefix: function (source) {
|
||||
return source.replace(/require\(['"]node:/g, "require('")
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
const crypto = require('crypto')
|
||||
const crypto = require('node:crypto')
|
||||
const { expect } = require('chai')
|
||||
const mockFs = require('mock-fs')
|
||||
const fs = require('fs')
|
||||
const fsPromises = require('fs/promises')
|
||||
const Path = require('path')
|
||||
const StreamPromises = require('stream/promises')
|
||||
const fs = require('node:fs')
|
||||
const fsPromises = require('node:fs/promises')
|
||||
const Path = require('node:path')
|
||||
const StreamPromises = require('node:stream/promises')
|
||||
const SandboxedModule = require('sandboxed-module')
|
||||
const Errors = require('../../src/Errors')
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
const { EventEmitter } = require('events')
|
||||
const { EventEmitter } = require('node:events')
|
||||
const sinon = require('sinon')
|
||||
const chai = require('chai')
|
||||
const { expect } = chai
|
||||
|
@ -45,11 +45,11 @@ describe('GcsPersistorTests', function () {
|
|||
|
||||
files = [
|
||||
{
|
||||
metadata: { size: 11, md5Hash: '/////wAAAAD/////AAAAAA==' },
|
||||
metadata: { size: '11', md5Hash: '/////wAAAAD/////AAAAAA==' },
|
||||
delete: sinon.stub(),
|
||||
},
|
||||
{
|
||||
metadata: { size: 22, md5Hash: '/////wAAAAD/////AAAAAA==' },
|
||||
metadata: { size: '22', md5Hash: '/////wAAAAD/////AAAAAA==' },
|
||||
delete: sinon.stub(),
|
||||
},
|
||||
]
|
||||
|
@ -63,7 +63,7 @@ describe('GcsPersistorTests', function () {
|
|||
|
||||
read() {
|
||||
if (this.err) return this.emit('error', this.err)
|
||||
this.emit('response', { statusCode: this.statusCode })
|
||||
this.emit('response', { statusCode: this.statusCode, headers: {} })
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -302,7 +302,7 @@ describe('GcsPersistorTests', function () {
|
|||
})
|
||||
|
||||
it('should return the object size', function () {
|
||||
expect(size).to.equal(files[0].metadata.size)
|
||||
expect(size).to.equal(11)
|
||||
})
|
||||
|
||||
it('should pass the bucket and key to GCS', function () {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
const chai = require('chai')
|
||||
const { expect } = chai
|
||||
const SandboxedModule = require('sandboxed-module')
|
||||
const StreamPromises = require('stream/promises')
|
||||
const StreamPromises = require('node:stream/promises')
|
||||
|
||||
const MODULE_PATH = '../../src/PersistorFactory.js'
|
||||
|
||||
|
@ -32,7 +32,7 @@ describe('PersistorManager', function () {
|
|||
Settings = {}
|
||||
const requires = {
|
||||
'./GcsPersistor': GcsPersistor,
|
||||
'./S3Persistor': S3Persistor,
|
||||
'./S3Persistor': { S3Persistor },
|
||||
'./FSPersistor': FSPersistor,
|
||||
'@overleaf/logger': {
|
||||
info() {},
|
||||
|
|
|
@ -3,7 +3,7 @@ const chai = require('chai')
|
|||
const { expect } = chai
|
||||
const SandboxedModule = require('sandboxed-module')
|
||||
const Errors = require('../../src/Errors')
|
||||
const { EventEmitter } = require('events')
|
||||
const { EventEmitter } = require('node:events')
|
||||
|
||||
const MODULE_PATH = '../../src/S3Persistor.js'
|
||||
|
||||
|
@ -91,8 +91,22 @@ describe('S3PersistorTests', function () {
|
|||
|
||||
createReadStream() {
|
||||
setTimeout(() => {
|
||||
if (this.notFoundSSEC) {
|
||||
// special case for AWS S3: 404 NoSuchKey wrapped in a 400. A single request received a single response, and multiple httpHeaders events are triggered. Don't ask.
|
||||
this.emit('httpHeaders', 400, {})
|
||||
this.emit('httpHeaders', 404, {})
|
||||
ReadStream.emit('error', S3NotFoundError)
|
||||
return
|
||||
}
|
||||
|
||||
if (this.err) return ReadStream.emit('error', this.err)
|
||||
this.emit('httpHeaders', this.statusCode)
|
||||
this.emit('httpHeaders', this.statusCode, {})
|
||||
if (this.statusCode === 403) {
|
||||
ReadStream.emit('error', S3AccessDeniedError)
|
||||
}
|
||||
if (this.statusCode === 404) {
|
||||
ReadStream.emit('error', S3NotFoundError)
|
||||
}
|
||||
})
|
||||
return ReadStream
|
||||
}
|
||||
|
@ -133,7 +147,7 @@ describe('S3PersistorTests', function () {
|
|||
deleteObjects: sinon.stub().returns(EmptyPromise),
|
||||
getSignedUrlPromise: sinon.stub().resolves(redirectUrl),
|
||||
}
|
||||
S3 = sinon.stub().returns(S3Client)
|
||||
S3 = sinon.stub().callsFake(() => Object.assign({}, S3Client))
|
||||
|
||||
Hash = {
|
||||
end: sinon.stub(),
|
||||
|
@ -159,7 +173,7 @@ describe('S3PersistorTests', function () {
|
|||
crypto,
|
||||
},
|
||||
globals: { console, Buffer },
|
||||
}))(settings)
|
||||
}).S3Persistor)(settings)
|
||||
})
|
||||
|
||||
describe('getObjectStream', function () {
|
||||
|
@ -338,6 +352,34 @@ describe('S3PersistorTests', function () {
|
|||
})
|
||||
})
|
||||
|
||||
describe("when the file doesn't exist -- SSEC", function () {
|
||||
let error, stream
|
||||
|
||||
beforeEach(async function () {
|
||||
S3GetObjectRequest.notFoundSSEC = 404
|
||||
try {
|
||||
stream = await S3Persistor.getObjectStream(bucket, key)
|
||||
} catch (err) {
|
||||
error = err
|
||||
}
|
||||
})
|
||||
it('does not return a stream', function () {
|
||||
expect(stream).not.to.exist
|
||||
})
|
||||
|
||||
it('throws a NotFoundError', function () {
|
||||
expect(error).to.be.an.instanceOf(Errors.NotFoundError)
|
||||
})
|
||||
|
||||
it('wraps the error', function () {
|
||||
expect(error.cause).to.exist
|
||||
})
|
||||
|
||||
it('stores the bucket and key in the error', function () {
|
||||
expect(error.info).to.include({ bucketName: bucket, key })
|
||||
})
|
||||
})
|
||||
|
||||
describe('when access to the file is denied', function () {
|
||||
let error, stream
|
||||
|
||||
|
@ -359,7 +401,7 @@ describe('S3PersistorTests', function () {
|
|||
})
|
||||
|
||||
it('wraps the error', function () {
|
||||
expect(error.cause).to.exist
|
||||
expect(error.cause).to.equal(S3AccessDeniedError)
|
||||
})
|
||||
|
||||
it('stores the bucket and key in the error', function () {
|
||||
|
@ -985,4 +1027,22 @@ describe('S3PersistorTests', function () {
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('_getClientForBucket', function () {
|
||||
it('should return same instance for same bucket', function () {
|
||||
const a = S3Persistor._getClientForBucket('foo')
|
||||
const b = S3Persistor._getClientForBucket('foo')
|
||||
expect(a).to.equal(b)
|
||||
})
|
||||
it('should return different instance for different bucket', function () {
|
||||
const a = S3Persistor._getClientForBucket('foo')
|
||||
const b = S3Persistor._getClientForBucket('bar')
|
||||
expect(a).to.not.equal(b)
|
||||
})
|
||||
it('should return different instance for same bucket different computeChecksums', function () {
|
||||
const a = S3Persistor._getClientForBucket('foo', false)
|
||||
const b = S3Persistor._getClientForBucket('foo', true)
|
||||
expect(a).to.not.equal(b)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
node_modules/
|
5
libraries/overleaf-editor-core/.gitignore
vendored
5
libraries/overleaf-editor-core/.gitignore
vendored
|
@ -1,5 +0,0 @@
|
|||
/coverage
|
||||
/node_modules
|
||||
|
||||
# managed by monorepo$ bin/update_build_scripts
|
||||
.npmrc
|
|
@ -1 +1 @@
|
|||
18.20.2
|
||||
22.17.0
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
overleaf-editor-core
|
||||
--dependencies=None
|
||||
--docker-repos=gcr.io/overleaf-ops
|
||||
--docker-repos=us-east1-docker.pkg.dev/overleaf-ops/ol-docker
|
||||
--env-add=
|
||||
--env-pass-through=
|
||||
--esmock-loader=False
|
||||
--is-library=True
|
||||
--node-version=18.20.2
|
||||
--node-version=22.17.0
|
||||
--public-repo=False
|
||||
--script-version=4.5.0
|
||||
--script-version=4.7.0
|
||||
|
|
|
@ -18,6 +18,7 @@ const MoveFileOperation = require('./lib/operation/move_file_operation')
|
|||
const SetCommentStateOperation = require('./lib/operation/set_comment_state_operation')
|
||||
const EditFileOperation = require('./lib/operation/edit_file_operation')
|
||||
const EditNoOperation = require('./lib/operation/edit_no_operation')
|
||||
const EditOperationTransformer = require('./lib/operation/edit_operation_transformer')
|
||||
const SetFileMetadataOperation = require('./lib/operation/set_file_metadata_operation')
|
||||
const NoOperation = require('./lib/operation/no_operation')
|
||||
const Operation = require('./lib/operation')
|
||||
|
@ -43,6 +44,8 @@ const TrackingProps = require('./lib/file_data/tracking_props')
|
|||
const Range = require('./lib/range')
|
||||
const CommentList = require('./lib/file_data/comment_list')
|
||||
const LazyStringFileData = require('./lib/file_data/lazy_string_file_data')
|
||||
const StringFileData = require('./lib/file_data/string_file_data')
|
||||
const EditOperationBuilder = require('./lib/operation/edit_operation_builder')
|
||||
|
||||
exports.AddCommentOperation = AddCommentOperation
|
||||
exports.Author = Author
|
||||
|
@ -58,6 +61,7 @@ exports.DeleteCommentOperation = DeleteCommentOperation
|
|||
exports.File = File
|
||||
exports.FileMap = FileMap
|
||||
exports.LazyStringFileData = LazyStringFileData
|
||||
exports.StringFileData = StringFileData
|
||||
exports.History = History
|
||||
exports.Label = Label
|
||||
exports.AddFileOperation = AddFileOperation
|
||||
|
@ -65,6 +69,8 @@ exports.MoveFileOperation = MoveFileOperation
|
|||
exports.SetCommentStateOperation = SetCommentStateOperation
|
||||
exports.EditFileOperation = EditFileOperation
|
||||
exports.EditNoOperation = EditNoOperation
|
||||
exports.EditOperationBuilder = EditOperationBuilder
|
||||
exports.EditOperationTransformer = EditOperationTransformer
|
||||
exports.SetFileMetadataOperation = SetFileMetadataOperation
|
||||
exports.NoOperation = NoOperation
|
||||
exports.Operation = Operation
|
||||
|
|
|
@ -40,6 +40,11 @@ class Blob {
|
|||
|
||||
static NotFoundError = NotFoundError
|
||||
|
||||
/**
|
||||
* @param {string} hash
|
||||
* @param {number} byteLength
|
||||
* @param {number} [stringLength]
|
||||
*/
|
||||
constructor(hash, byteLength, stringLength) {
|
||||
this.setHash(hash)
|
||||
this.setByteLength(byteLength)
|
||||
|
@ -63,14 +68,14 @@ class Blob {
|
|||
|
||||
/**
|
||||
* Hex hash.
|
||||
* @return {?String}
|
||||
* @return {String}
|
||||
*/
|
||||
getHash() {
|
||||
return this.hash
|
||||
}
|
||||
|
||||
setHash(hash) {
|
||||
assert.maybe.match(hash, Blob.HEX_HASH_RX, 'bad hash')
|
||||
assert.match(hash, Blob.HEX_HASH_RX, 'bad hash')
|
||||
this.hash = hash
|
||||
}
|
||||
|
||||
|
@ -83,13 +88,13 @@ class Blob {
|
|||
}
|
||||
|
||||
setByteLength(byteLength) {
|
||||
assert.maybe.integer(byteLength, 'bad byteLength')
|
||||
assert.integer(byteLength, 'bad byteLength')
|
||||
this.byteLength = byteLength
|
||||
}
|
||||
|
||||
/**
|
||||
* Utf-8 length of the blob content, if it appears to be valid UTF-8.
|
||||
* @return {?number}
|
||||
* @return {number|undefined}
|
||||
*/
|
||||
getStringLength() {
|
||||
return this.stringLength
|
||||
|
|
|
@ -13,7 +13,7 @@ const V2DocVersions = require('./v2_doc_versions')
|
|||
|
||||
/**
|
||||
* @import Author from "./author"
|
||||
* @import { BlobStore } from "./types"
|
||||
* @import { BlobStore, RawChange, ReadonlyBlobStore } from "./types"
|
||||
*/
|
||||
|
||||
/**
|
||||
|
@ -54,7 +54,7 @@ class Change {
|
|||
/**
|
||||
* For serialization.
|
||||
*
|
||||
* @return {Object}
|
||||
* @return {RawChange}
|
||||
*/
|
||||
toRaw() {
|
||||
function toRaw(object) {
|
||||
|
@ -100,6 +100,9 @@ class Change {
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Operation[]}
|
||||
*/
|
||||
getOperations() {
|
||||
return this.operations
|
||||
}
|
||||
|
@ -216,7 +219,7 @@ class Change {
|
|||
* If this Change contains any File objects, load them.
|
||||
*
|
||||
* @param {string} kind see {File#load}
|
||||
* @param {BlobStore} blobStore
|
||||
* @param {ReadonlyBlobStore} blobStore
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async loadFiles(kind, blobStore) {
|
||||
|
@ -248,6 +251,24 @@ class Change {
|
|||
* @param {boolean} [opts.strict] - Do not ignore recoverable errors
|
||||
*/
|
||||
applyTo(snapshot, opts = {}) {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
for (const operation of this.iterativelyApplyTo(snapshot, opts)) {
|
||||
// Nothing to do: we're just consuming the iterator for the side effects
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generator that applies this change to a snapshot and yields each
|
||||
* operation after it has been applied.
|
||||
*
|
||||
* Recoverable errors (caused by historical bad data) are ignored unless
|
||||
* opts.strict is true
|
||||
*
|
||||
* @param {Snapshot} snapshot modified in place
|
||||
* @param {object} opts
|
||||
* @param {boolean} [opts.strict] - Do not ignore recoverable errors
|
||||
*/
|
||||
*iterativelyApplyTo(snapshot, opts = {}) {
|
||||
assert.object(snapshot, 'bad snapshot')
|
||||
|
||||
for (const operation of this.operations) {
|
||||
|
@ -261,6 +282,7 @@ class Change {
|
|||
throw err
|
||||
}
|
||||
}
|
||||
yield operation
|
||||
}
|
||||
|
||||
// update project version if present in change
|
||||
|
|
|
@ -10,7 +10,7 @@ const Change = require('./change')
|
|||
class ChangeNote {
|
||||
/**
|
||||
* @param {number} baseVersion the new base version for the change
|
||||
* @param {?Change} change
|
||||
* @param {Change} [change]
|
||||
*/
|
||||
constructor(baseVersion, change) {
|
||||
assert.integer(baseVersion, 'bad baseVersion')
|
||||
|
|
|
@ -95,7 +95,7 @@ class File {
|
|||
|
||||
/**
|
||||
* @param {number} byteLength
|
||||
* @param {number?} stringLength
|
||||
* @param {number} [stringLength]
|
||||
* @param {Object} [metadata]
|
||||
* @return {File}
|
||||
*/
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// @ts-check
|
||||
|
||||
/**
|
||||
* @import { ClearTrackingPropsRawData } from '../types'
|
||||
* @import { ClearTrackingPropsRawData, TrackingDirective } from '../types'
|
||||
*/
|
||||
|
||||
class ClearTrackingProps {
|
||||
|
@ -11,12 +11,27 @@ class ClearTrackingProps {
|
|||
|
||||
/**
|
||||
* @param {any} other
|
||||
* @returns {boolean}
|
||||
* @returns {other is ClearTrackingProps}
|
||||
*/
|
||||
equals(other) {
|
||||
return other instanceof ClearTrackingProps
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {TrackingDirective} other
|
||||
* @returns {other is ClearTrackingProps}
|
||||
*/
|
||||
canMergeWith(other) {
|
||||
return other instanceof ClearTrackingProps
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {TrackingDirective} other
|
||||
*/
|
||||
mergeWith(other) {
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {ClearTrackingPropsRawData}
|
||||
*/
|
||||
|
|
|
@ -47,7 +47,7 @@ class FileData {
|
|||
|
||||
/** @see File.createHollow
|
||||
* @param {number} byteLength
|
||||
* @param {number|null} stringLength
|
||||
* @param {number} [stringLength]
|
||||
*/
|
||||
static createHollow(byteLength, stringLength) {
|
||||
if (stringLength == null) {
|
||||
|
@ -63,20 +63,14 @@ class FileData {
|
|||
*/
|
||||
static createLazyFromBlobs(blob, rangesBlob) {
|
||||
assert.instance(blob, Blob, 'FileData: bad blob')
|
||||
if (blob.getStringLength() == null) {
|
||||
return new BinaryFileData(
|
||||
// TODO(das7pad): see call-sites
|
||||
// @ts-ignore
|
||||
blob.getHash(),
|
||||
blob.getByteLength()
|
||||
)
|
||||
const stringLength = blob.getStringLength()
|
||||
if (stringLength == null) {
|
||||
return new BinaryFileData(blob.getHash(), blob.getByteLength())
|
||||
}
|
||||
return new LazyStringFileData(
|
||||
// TODO(das7pad): see call-sites
|
||||
// @ts-ignore
|
||||
blob.getHash(),
|
||||
rangesBlob?.getHash(),
|
||||
blob.getStringLength()
|
||||
stringLength
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@ const EditOperation = require('../operation/edit_operation')
|
|||
const EditOperationBuilder = require('../operation/edit_operation_builder')
|
||||
|
||||
/**
|
||||
* @import { BlobStore, ReadonlyBlobStore, RangesBlob, RawFileData, RawLazyStringFileData } from '../types'
|
||||
* @import { BlobStore, ReadonlyBlobStore, RangesBlob, RawHashFileData, RawLazyStringFileData } from '../types'
|
||||
*/
|
||||
|
||||
class LazyStringFileData extends FileData {
|
||||
|
@ -159,11 +159,11 @@ class LazyStringFileData extends FileData {
|
|||
|
||||
/** @inheritdoc
|
||||
* @param {BlobStore} blobStore
|
||||
* @return {Promise<RawFileData>}
|
||||
* @return {Promise<RawHashFileData>}
|
||||
*/
|
||||
async store(blobStore) {
|
||||
if (this.operations.length === 0) {
|
||||
/** @type RawFileData */
|
||||
/** @type RawHashFileData */
|
||||
const raw = { hash: this.hash }
|
||||
if (this.rangesHash) {
|
||||
raw.rangesHash = this.rangesHash
|
||||
|
@ -171,9 +171,11 @@ class LazyStringFileData extends FileData {
|
|||
return raw
|
||||
}
|
||||
const eager = await this.toEager(blobStore)
|
||||
const raw = await eager.store(blobStore)
|
||||
this.hash = raw.hash
|
||||
this.rangesHash = raw.rangesHash
|
||||
this.operations.length = 0
|
||||
/** @type RawFileData */
|
||||
return await eager.store(blobStore)
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@ const CommentList = require('./comment_list')
|
|||
const TrackedChangeList = require('./tracked_change_list')
|
||||
|
||||
/**
|
||||
* @import { StringFileRawData, RawFileData, BlobStore, CommentRawData } from "../types"
|
||||
* @import { StringFileRawData, RawHashFileData, BlobStore, CommentRawData } from "../types"
|
||||
* @import { TrackedChangeRawData, RangesBlob } from "../types"
|
||||
* @import EditOperation from "../operation/edit_operation"
|
||||
*/
|
||||
|
@ -88,6 +88,14 @@ class StringFileData extends FileData {
|
|||
return content
|
||||
}
|
||||
|
||||
/**
|
||||
* Return docstore view of a doc: each line separated
|
||||
* @return {string[]}
|
||||
*/
|
||||
getLines() {
|
||||
return this.getContent({ filterTrackedDeletes: true }).split('\n')
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
getByteLength() {
|
||||
return Buffer.byteLength(this.content)
|
||||
|
@ -131,7 +139,7 @@ class StringFileData extends FileData {
|
|||
/**
|
||||
* @inheritdoc
|
||||
* @param {BlobStore} blobStore
|
||||
* @return {Promise<RawFileData>}
|
||||
* @return {Promise<RawHashFileData>}
|
||||
*/
|
||||
async store(blobStore) {
|
||||
const blob = await blobStore.putString(this.content)
|
||||
|
@ -142,12 +150,8 @@ class StringFileData extends FileData {
|
|||
trackedChanges: this.trackedChanges.toRaw(),
|
||||
}
|
||||
const rangesBlob = await blobStore.putObject(ranges)
|
||||
// TODO(das7pad): Provide interface that guarantees hash exists?
|
||||
// @ts-ignore
|
||||
return { hash: blob.getHash(), rangesHash: rangesBlob.getHash() }
|
||||
}
|
||||
// TODO(das7pad): Provide interface that guarantees hash exists?
|
||||
// @ts-ignore
|
||||
return { hash: blob.getHash() }
|
||||
}
|
||||
}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue