mirror of
https://github.com/sasjs/server.git
synced 2025-12-10 19:34:34 +00:00
Compare commits
926 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bee4f215d2 | ||
|
|
100f138f98 | ||
| 6ffaa7e9e2 | |||
|
|
a433786011 | ||
|
|
1adff9a783 | ||
| 1435e380be | |||
| e099f2e678 | |||
| ddd155ba01 | |||
| 9936241815 | |||
| 570995e572 | |||
| 462829fd9a | |||
| c1c0554de2 | |||
| bd3aff9a7b | |||
| a1e255e0c7 | |||
| 0dae034f17 | |||
| 89048ce943 | |||
| a82cabb001 | |||
| c4066d32a0 | |||
|
|
6a44cd69d9 | ||
|
|
e607115995 | ||
| edab51c519 | |||
|
|
081cc3102c | ||
|
|
b19aa1eba4 | ||
| 2c31922f58 | |||
|
|
4d7a571a6e | ||
|
|
a373a4eb5f | ||
| 5e3ce8a98f | |||
|
|
737b34567e | ||
|
|
6373442f83 | ||
|
|
3de59ac4f8 | ||
|
|
941988cd7c | ||
| 158f044363 | |||
|
|
02ae041a81 | ||
|
|
c4c84b1537 | ||
| b3402ea80a | |||
|
|
abe942e697 | ||
|
|
faf2edb111 | ||
| 5bec453e89 | |||
| 7f2174dd2c | |||
| 2bae52e307 | |||
|
|
b243e62ece | ||
|
|
88c3056e97 | ||
| 203303b659 | |||
| 835709bd36 | |||
| 69f2576ee6 | |||
|
|
305077f36e | ||
|
|
96eca3a35d | ||
|
|
0f5c815c25 | ||
|
|
acccef1e99 | ||
| abc34ea047 | |||
| 71c429b093 | |||
|
|
c126f2d5d9 | ||
|
|
34dd95d16e | ||
| 1192583843 | |||
|
|
518815acf1 | ||
|
|
80b7e14ed5 | ||
| 23c997b3be | |||
| 39ba995355 | |||
|
|
0e081e024b | ||
|
|
6a84bd0387 | ||
| 98d177a691 | |||
| 4dcee4b3c3 | |||
|
|
4ffc1ec6a9 | ||
|
|
5a1d168e83 | ||
|
|
515c976685 | ||
| 112431a1b7 | |||
| c26485afec | |||
| 1d48f8856b | |||
| 68758aa616 | |||
| 8b8c43c21b | |||
| 4581f32534 | |||
| b47e74a7e1 | |||
| b27d684145 | |||
|
|
6b666d5554 | ||
|
|
b5f0911858 | ||
| b86ba5b8a3 | |||
| 200f6c596a | |||
|
|
1b7ccda6e9 | ||
|
|
532035d835 | ||
|
|
7ae862c5ce | ||
|
|
ab5858b8af | ||
|
|
a39f5dd9f1 | ||
|
|
3ea444756c | ||
|
|
96399ecbbe | ||
| bb054938c5 | |||
|
|
fb6a556630 | ||
|
|
9dbd8e16bd | ||
| fe07c41f5f | |||
| acc25cbd68 | |||
| 4ca61feda6 | |||
| abd5c64b4a | |||
| 2413c05fea | |||
|
|
4c874c2c39 | ||
|
|
d819d79bc9 | ||
| c51b50428f | |||
|
|
e10a0554f0 | ||
|
|
337e2eb2a0 | ||
| 66f8e7840b | |||
| 1c9d167f86 | |||
|
|
7e684b54a6 | ||
|
|
aafda2922b | ||
| 418bf41e38 | |||
| 81f0b03b09 | |||
| fe5ae44aab | |||
| 36be3a7d5e | |||
| 6434123401 | |||
|
|
0a6b972c65 | ||
|
|
be11707042 | ||
| 2412622367 | |||
|
|
de3a190a8d | ||
|
|
d5daafc6ed | ||
|
|
b1a2677b8c | ||
|
|
94072c3d24 | ||
|
|
b64c0c12da | ||
|
|
79bc7b0e28 | ||
|
|
fda0e0b57d | ||
|
|
14731e8824 | ||
|
|
258cc35f14 | ||
|
|
2295a518f0 | ||
|
|
1e5d621817 | ||
| 4d64420c45 | |||
|
|
799339de30 | ||
|
|
042ed41189 | ||
| 424f0fc1fa | |||
|
|
deafebde05 | ||
|
|
b66dc86b01 | ||
|
|
3bb05974d2 | ||
|
|
d1c1a59e91 | ||
|
|
668aff83fd | ||
| 3fc06b80fc | |||
| bbd7786c6c | |||
| 68f0c5c588 | |||
|
|
69ddf313b8 | ||
|
|
65e404cdbd | ||
| a14266077d | |||
|
|
fda6ad6356 | ||
|
|
fe3e5088f8 | ||
| f915c51b07 | |||
|
|
375f924f45 | ||
|
|
72329e30ed | ||
| 40f95f9072 | |||
|
|
58e8a869ef | ||
|
|
b558a3d01d | ||
| 249604384e | |||
|
|
056a436e10 | ||
|
|
06d59c618c | ||
|
|
a0e7875ae6 | ||
|
|
24966e695a | ||
|
|
5c40d8a342 | ||
| 6f5566dabb | |||
| d93470d183 | |||
| 330c020933 | |||
|
|
a810f6c7cf | ||
|
|
5d6c6086b4 | ||
|
|
0edcbdcefc | ||
|
|
ea0222f218 | ||
| edc2e2a302 | |||
|
|
efd2e1450e | ||
|
|
1092a73c10 | ||
| 9977c9d161 | |||
|
|
5c0eff5197 | ||
|
|
3bda991a58 | ||
| 0327f7c6ec | |||
| 92549402eb | |||
|
|
b88c911527 | ||
|
|
8b12f31060 | ||
|
|
e65cba9af0 | ||
| 0749d65173 | |||
|
|
a9c9b734f5 | ||
|
|
39da41c9f1 | ||
| 662b2ca36a | |||
| 16b7aa6abb | |||
| 4560ef942f | |||
| 06d3b17154 | |||
| d6651bbdbe | |||
| b9d032f148 | |||
|
|
70655e74d3 | ||
|
|
cb82fea0d8 | ||
| b9a596616d | |||
|
|
72a5393be3 | ||
|
|
769a840e9f | ||
| 730c7c52ac | |||
| ee2db276bb | |||
|
|
d0a24aacb6 | ||
|
|
57dfdf89a4 | ||
|
|
393b5eaf99 | ||
|
|
7477326b22 | ||
|
|
76bf84316e | ||
|
|
e355276e44 | ||
|
|
a3a9e3bd9f | ||
|
|
9f06080348 | ||
|
|
4bbf9cfdb3 | ||
|
|
e8e71fcde9 | ||
|
|
e63271a67a | ||
| 7633608318 | |||
|
|
e67d27d264 | ||
|
|
53033ccc96 | ||
|
|
6131ed1cbe | ||
|
|
5d624e3399 | ||
| ee17d37aa1 | |||
| 572fe22d50 | |||
| 091268bf58 | |||
| 71a4a48443 | |||
| 3b188cd724 | |||
| eeba2328c0 | |||
| 0a0ba2cca5 | |||
|
|
476f834a80 | ||
|
|
8b8739a873 | ||
| bce83cb6fb | |||
| 3a3c90d9e6 | |||
|
|
e63eaa5302 | ||
|
|
65de1bb175 | ||
|
|
a5ee2f2923 | ||
| 98ea2ac9b9 | |||
|
|
e94c56b23f | ||
|
|
64f80e958d | ||
| bd97363c13 | |||
| 02e88ae728 | |||
| 882bedd5d5 | |||
| 8780b800a3 | |||
| 4c11082796 | |||
| a9b25b8880 | |||
| b06993ab9e | |||
|
|
f736e67517 | ||
|
|
0f4a60c0c7 | ||
|
|
f8bb7327a8 | ||
|
|
abce135da2 | ||
|
|
a6c014946a | ||
| f27ac51fc4 | |||
|
|
cb5be1be21 | ||
|
|
d90fa9e5dd | ||
| d99fdd1ec7 | |||
|
|
399b5edad0 | ||
|
|
1dbc12e96b | ||
| e215958b8b | |||
| 9227cd449d | |||
| c67d3ee2f1 | |||
| 6ef40b954a | |||
|
|
0d913baff1 | ||
|
|
3671736c3d | ||
| 34cd84d8a9 | |||
|
|
f7fcc7741a | ||
|
|
18052fdbf6 | ||
|
|
5966016853 | ||
|
|
87c03c5f8d | ||
| 7a162eda8f | |||
| 754704bca8 | |||
|
|
77f8d30baf | ||
|
|
78bea7c154 | ||
|
|
9c3b155c12 | ||
|
|
98e501334f | ||
|
|
bbfd53e79e | ||
| 254bc07da7 | |||
| f978814ca7 | |||
| 68515f95a6 | |||
| d3a516c36e | |||
| c3e3befc17 | |||
|
|
275de9478e | ||
|
|
1a3ef62cb2 | ||
|
|
9eb5f3ca4d | ||
|
|
916947dffa | ||
| 79b7827b7c | |||
| 37e1aa9b61 | |||
| 7e504008b7 | |||
| 5d5a9d3788 | |||
|
|
7c79d6479c | ||
|
|
3e635f422a | ||
|
|
77db14c690 | ||
| b7dff341f0 | |||
| 8a3054e19a | |||
|
|
a531de2adb | ||
|
|
c458d94493 | ||
| 706e228a8e | |||
| 7681722e5a | |||
| 8de032b543 | |||
|
|
998ef213e9 | ||
|
|
f8b0f98678 | ||
| 9640f65264 | |||
| c574b42235 | |||
| 468d1a929d | |||
| 7cdffe30e3 | |||
| 3b1fcb937d | |||
| 3c987c61dd | |||
| 0a780697da | |||
| 83d819df53 | |||
|
|
95df2b21d6 | ||
|
|
accdf914f1 | ||
| 15bdd2d7f0 | |||
| 2ce947d216 | |||
| ce2114e3f6 | |||
| 6c7550286b | |||
| 2360e104bd | |||
| 420a61a5a6 | |||
| 04e0f9efe3 | |||
| 99172cd9ed | |||
| 57daad0c26 | |||
| cc1e4543fc | |||
| 03cb89d14f | |||
| 72140d73c2 | |||
| efcefd2a42 | |||
| 06d7c91fc3 | |||
| 7010a6a120 | |||
| fdcaba9d56 | |||
| 48688a6547 | |||
| 0ce94a553e | |||
| 941917e508 | |||
|
|
5706371ffd | ||
|
|
ce5218a227 | ||
|
|
8b62755f39 | ||
|
|
cb84c3ebbb | ||
|
|
526402fd73 | ||
| 177675bc89 | |||
| 721165ff12 | |||
| 08e0c61e0f | |||
|
|
1b234eb2b1 | ||
|
|
ef25eec11f | ||
| 3e53f70928 | |||
| 0f19384999 | |||
| 63dd6813c0 | |||
| 299512135d | |||
| 6c35412d2f | |||
| 27410bc32b | |||
| 849b2dd468 | |||
|
|
a1a182698e | ||
|
|
4be692b24b | ||
|
|
d2ddd8aaca | ||
|
|
3a45e8f525 | ||
|
|
c0e2f55a7b | ||
|
|
aa027414ed | ||
|
|
8c4c52b1a9 | ||
|
|
ff420434ae | ||
|
|
65e6de9663 | ||
|
|
2e53d43e11 | ||
|
|
3795f748a7 | ||
|
|
e024a92f16 | ||
|
|
92fda183f3 | ||
|
|
6f2e6efd03 | ||
| 30d7a65358 | |||
| 5e930f14d2 | |||
| 9bc68b1cdc | |||
|
|
3b4e9d20d4 | ||
|
|
4a67d0c63a | ||
|
|
dea204e3c5 | ||
|
|
5f9e83759c | ||
|
|
fefe63deb1 | ||
| ddd179bbee | |||
| a10b87930c | |||
| 496247d0b9 | |||
| eeb63b330c | |||
|
|
1108d3dd7b | ||
|
|
7edb47a4cb | ||
|
|
451cb4f6dd | ||
|
|
0b759a5594 | ||
|
|
5338ffb211 | ||
| e42fdd3575 | |||
| b10e932605 | |||
| e54a09db19 | |||
| 4c35e04802 | |||
| b5f595a25c | |||
|
|
a131adbae7 | ||
|
|
a20c3b9719 | ||
|
|
eee3a7b084 | ||
|
|
9c3da56901 | ||
|
|
7e6524d7e4 | ||
|
|
0ea2690616 | ||
|
|
b369759f0f | ||
|
|
ac9a835c5a | ||
|
|
e290751c87 | ||
| e516b7716d | |||
| f3dfc7083f | |||
| 7d916ec3e9 | |||
| 70f279a49c | |||
| 66a3537271 | |||
| ca64c13909 | |||
| 0a73a35547 | |||
| a75edbaa32 | |||
| 4ddfec0403 | |||
| 35439d7d51 | |||
| 907aa485fd | |||
| 888627e1c8 | |||
| 9cb9e2dd33 | |||
| 54d4bf835d | |||
| 67fe298fd5 | |||
| 97ecfdc955 | |||
| 5b319f9ad1 | |||
| be8635ccc5 | |||
| f863b81a7d | |||
| bdf63df1d9 | |||
| 4c6b9c5e93 | |||
|
|
a2d1396057 | ||
|
|
b2f21eb3ac | ||
|
|
71bcbb9134 | ||
|
|
c86f0feff8 | ||
|
|
d3d2ab9a36 | ||
| 5cc85b57f8 | |||
|
|
ae0fc0c48c | ||
|
|
555c5d54e2 | ||
| 1b5859ee37 | |||
| 65380be2f3 | |||
|
|
1933be15c2 | ||
|
|
56b20beb8c | ||
|
|
bfc5ac6a4f | ||
|
|
6376173de0 | ||
|
|
3130fbeff0 | ||
|
|
01e9a1d9e9 | ||
|
|
2119e9de9a | ||
|
|
87dbab98f6 | ||
|
|
1bf122a0a2 | ||
|
|
5d5d6ce326 | ||
|
|
620eddb713 | ||
|
|
3c92034da3 | ||
|
|
f6dc74f16b | ||
|
|
8c48d00d21 | ||
|
|
48ff8d73d4 | ||
| eb397b15c2 | |||
| eb569c7b82 | |||
| 99a1107364 | |||
| 91d29cb127 | |||
|
|
1e2c08a8d3 | ||
|
|
473fbd62c0 | ||
|
|
b1a0fe7060 | ||
| dde293c852 | |||
| f738a6d7a3 | |||
|
|
3e0a2de2ad | ||
|
|
91cb7bd946 | ||
|
|
a501a300dc | ||
|
|
b446baa822 | ||
| 9023cf33b5 | |||
| 23b6692f02 | |||
|
|
6de91618ff | ||
|
|
e06d66f312 | ||
|
|
1ffaf2e0ef | ||
|
|
393d3327db | ||
|
|
14cfb9a663 | ||
|
|
dd1f2b3ed7 | ||
|
|
9f5dbbc8da | ||
|
|
9423bb2b23 | ||
|
|
5bfcdc4dbb | ||
|
|
ecd8ed9032 | ||
|
|
a8d89ff1d6 | ||
|
|
8702a4e8fd | ||
| ab222cbaab | |||
|
|
5f06132ece | ||
|
|
56c80b0979 | ||
| 158acf1f97 | |||
|
|
c19a20c1d4 | ||
|
|
f8eaadae7b | ||
| 90e0973a7f | |||
| 869a13fc69 | |||
| 1790e10fc1 | |||
|
|
6d12b900ad | ||
|
|
ae5aa02733 | ||
|
|
28a6a36bb7 | ||
|
|
4e7579dc10 | ||
| 6b0b94ad38 | |||
|
|
b81d742c6c | ||
|
|
a61adbcac2 | ||
|
|
12000f4fc7 | ||
| 73792fb574 | |||
| 53854d0012 | |||
|
|
81501d17ab | ||
|
|
11a7f920f1 | ||
|
|
c08cfcbc38 | ||
|
|
8d38d5ac64 | ||
| e08bbcc543 | |||
|
|
eef3cb270d | ||
|
|
9cfbca23f8 | ||
|
|
aef411a0ea | ||
|
|
e359265c4b | ||
|
|
8e7c9e671c | ||
|
|
c830f44e29 | ||
|
|
806ea4cb5c | ||
|
|
7205072358 | ||
|
|
32d372b42f | ||
|
|
e059bee7dc | ||
|
|
6f56aafab1 | ||
|
|
8734489cf0 | ||
| de9ed15286 | |||
| 325285f447 | |||
|
|
7e6635f40f | ||
|
|
c0022a22f4 | ||
|
|
3fa2a7e2e3 | ||
| 8a617a73ae | |||
| 16856165fb | |||
|
|
e7babb9f55 | ||
|
|
5ab35b02c4 | ||
| 058b3b0081 | |||
| 9d5a5e051f | |||
| 2c704a544f | |||
| 6d6bda5626 | |||
| dffe6d7121 | |||
| b4443819d4 | |||
| e5a7674fa1 | |||
| 596ada7ca8 | |||
| f561ba4bf0 | |||
| c58666eb81 | |||
| 5df619b3f6 | |||
| 07295aa151 | |||
| 194eaec7d4 | |||
|
|
ad82ee7106 | ||
|
|
d2e9456d81 | ||
|
|
e6d1989847 | ||
|
|
7a932383b4 | ||
|
|
576e18347e | ||
|
|
61815f8ae1 | ||
|
|
afff27fd21 | ||
|
|
a8ba378fd1 | ||
|
|
73c81a45dc | ||
|
|
12d424acce | ||
|
|
414fb19de3 | ||
|
|
cfddf1fb0c | ||
|
|
1f483b1afc | ||
|
|
0470239ef1 | ||
|
|
2c259fe1de | ||
|
|
b066734398 | ||
|
|
3b698fce5f | ||
|
|
5ad6ee5e0f | ||
|
|
7d11cc7916 | ||
|
|
ff1def6436 | ||
|
|
c275db184e | ||
|
|
e4239fbcc3 | ||
|
|
c6fd8fdd70 | ||
|
|
79dc2dba23 | ||
|
|
2a7223ad7d | ||
|
|
1fed5ea6ac | ||
|
|
97f689f292 | ||
|
|
53bf68a6af | ||
|
|
f37f8e95d1 | ||
|
|
80b33c7a18 | ||
| fa63dc071b | |||
| e8c21a43b2 | |||
| 1413b18508 | |||
| dfbd155711 | |||
| 4fcc191ce9 | |||
| d000f7508f | |||
| 5652325452 | |||
|
|
b1803fe385 | ||
|
|
7dd08c3b5b | ||
|
|
b780b59b66 | ||
|
|
7b457eaec5 | ||
|
|
c017d13061 | ||
| 0781ddd64e | |||
|
|
c2b5e353a5 | ||
|
|
f89389bbc6 | ||
|
|
fadcc9bd29 | ||
|
|
182def2f3e | ||
|
|
06a5f39fea | ||
|
|
143b367a0e | ||
|
|
b5fd800300 | ||
|
|
a0b52d9982 | ||
|
|
c4212665c8 | ||
|
|
97d9bc191c | ||
|
|
dd2a403985 | ||
|
|
7cfa2398e1 | ||
|
|
5888f04e08 | ||
|
|
b40de8fa6a | ||
|
|
45a2a01532 | ||
|
|
c61fec47c4 | ||
| 24d7f00c02 | |||
| b0fdaaaa79 | |||
| 7be77cc38a | |||
| 98b8a75148 | |||
| 72a3197a06 | |||
| fce05d6959 | |||
| 1aec3abd28 | |||
|
|
2467616296 | ||
| 9136c95013 | |||
|
|
ceefbe48e9 | ||
|
|
426e90471e | ||
|
|
c0b57b9e76 | ||
|
|
4a8e32dd20 | ||
|
|
636301e664 | ||
|
|
25dc5dd215 | ||
|
|
503994dbd2 | ||
|
|
0dceb5c3c3 | ||
|
|
1af04fa3b3 | ||
|
|
efa81fec77 | ||
|
|
10caf1918a | ||
|
|
4ed20a3b75 | ||
|
|
98b2c5fa25 | ||
|
|
3ad327b85f | ||
|
|
dd3acce393 | ||
|
|
8065727b9b | ||
|
|
e1223ec3f8 | ||
|
|
1f89279264 | ||
|
|
a07f47a1ba | ||
|
|
2548c82dfe | ||
|
|
238aa1006f | ||
|
|
35cba97611 | ||
|
|
5f29dec16f | ||
|
|
e2a97fcb7c | ||
|
|
6adeeefcf5 | ||
|
|
c9d66b8576 | ||
|
|
5aaac24080 | ||
|
|
6d34206bbc | ||
|
|
7b39cc06d3 | ||
|
|
6e7f28a6f8 | ||
|
|
5689169ce4 | ||
|
|
6139e7bff6 | ||
|
|
2c77317bb9 | ||
|
|
57b63db9cb | ||
|
|
60a2a4fe32 | ||
|
|
09611cb416 | ||
|
|
2a9bb6e6b1 | ||
|
|
b4b60c69cf | ||
|
|
b060ad1b8e | ||
|
|
89b32e70ff | ||
| 01713440a4 | |||
| 540f54fb77 | |||
|
|
d47ed6d0e8 | ||
| bf906aa544 | |||
| 797c2bcc39 | |||
| 1103ffe07b | |||
| e5200c1000 | |||
| 38a7db8514 | |||
| 39fc908de1 | |||
|
|
a6993ef5ae | ||
|
|
2571fc2ca8 | ||
|
|
992f39b63a | ||
|
|
1ea3f6d8b3 | ||
|
|
e462aebdc0 | ||
| be009d5b02 | |||
| 6bea1f7666 | |||
|
|
13403517a4 | ||
|
|
c3c2048e75 | ||
|
|
1d8acc36eb | ||
|
|
4c7ad56326 | ||
|
|
e57443f1ed | ||
|
|
5da93f318a | ||
|
|
a30fb1a241 | ||
|
|
4ae8f35e9a | ||
|
|
ebb46f51b6 | ||
|
|
fe24f51ca2 | ||
|
|
fd15f3fb41 | ||
|
|
7d31ee7696 | ||
|
|
667e26b080 | ||
|
|
d09876c05f | ||
|
|
fb8e18be75 | ||
|
|
7ac7a4e083 | ||
|
|
8e23786dd4 | ||
|
|
4bd01bcf29 | ||
|
|
4ad8c81e49 | ||
|
|
51f6aa34a1 | ||
|
|
486207128d | ||
|
|
1e4b0b9171 | ||
|
|
1ff820605a | ||
|
|
9c1a781b3a | ||
| 36628551ae | |||
| 23cf8fa06f | |||
| 84ee743eae | |||
|
|
19e5bd7d2d | ||
|
|
e251747302 | ||
|
|
7e7558d4cf | ||
|
|
f02996facf | ||
|
|
803c51f400 | ||
|
|
c35b2b3f59 | ||
|
|
fe0866ace7 | ||
|
|
1513c3623d | ||
|
|
7fe43ae0b7 | ||
|
|
c4cea4a12b | ||
|
|
9fc7a132ba | ||
|
|
d55a619d64 | ||
|
|
737d2a24c2 | ||
|
|
2e63831b90 | ||
|
|
c7ffde1a3b | ||
|
|
db70b1ce55 | ||
|
|
8a3fe8b217 | ||
| 9dca552e82 | |||
|
|
505f2089c7 | ||
|
|
3344c400a8 | ||
| fa6248e3ef | |||
| 9fb5f1f8e7 | |||
|
|
92e0b8a088 | ||
|
|
b484306ed8 | ||
| 5e08aacc51 | |||
| a9e4eb685d | |||
| 31b09f27cc | |||
| 9f3ec92f8e | |||
| 6c9e449614 | |||
| 68e84b0994 | |||
| f0bb51a0d5 | |||
| b93a0da3a3 | |||
|
|
e5facbf54c | ||
|
|
cb2bebbe76 | ||
|
|
9e1e0ce8cc | ||
|
|
29928753b7 | ||
|
|
edd69ecaae | ||
|
|
74ba65f9f3 | ||
|
|
f257602834 | ||
|
|
61080d4694 | ||
|
|
82633adbc4 | ||
|
|
23db7e7b7d | ||
|
|
cbaa687c9b | ||
|
|
527f70e90d | ||
|
|
122faad55f | ||
|
|
3ff6f5e865 | ||
|
|
7d5128c0d6 | ||
|
|
e1ebbfd087 | ||
|
|
e430bdb0d4 | ||
|
|
9d9769eef3 | ||
|
|
9d167abe2a | ||
|
|
18d0604bdd | ||
|
|
7b7bc6b778 | ||
|
|
fb4f3442d5 | ||
|
|
09d1b7d5d4 | ||
|
|
99839ae62f | ||
|
|
f700561e1a | ||
|
|
8b4b4b91ab | ||
|
|
acb3ae0493 | ||
|
|
f48aeb1b0b | ||
|
|
5c0e8e5344 | ||
|
|
0ac9e4af7d | ||
|
|
ee80f3f968 | ||
|
|
7f4201ba85 | ||
|
|
f830bbc058 | ||
|
|
f8e1522a5a | ||
|
|
0a5aeceab5 | ||
|
|
6dc39c0d91 | ||
|
|
117a53ceea | ||
|
|
dd56a95314 | ||
|
|
c5117abe71 | ||
|
|
84c632a861 | ||
|
|
3ddd09eba0 | ||
|
|
0c0301433c | ||
|
|
954b2e3e2e | ||
|
|
5655311b96 | ||
|
|
9ace33d783 | ||
|
|
adc5aca0f0 | ||
|
|
71c6be6b84 | ||
|
|
9c751877d1 | ||
|
|
2204d54cd6 | ||
|
|
f4eb75ff34 | ||
|
|
a3cde343b7 | ||
|
|
7a70d40dbf | ||
|
|
d27e070fc8 | ||
|
|
27e260e6a4 | ||
|
|
2796db8ead | ||
|
|
84f7c2ab89 | ||
|
|
e68090181a | ||
|
|
d2956fc641 | ||
|
|
a701bb25e7 | ||
|
|
5758bcd392 | ||
|
|
9e53470947 | ||
|
|
81f6605249 | ||
|
|
0b45402946 | ||
|
|
9ac3191891 | ||
|
|
cd00aa2af8 | ||
|
|
0147bcb701 | ||
|
|
bf53ad30f4 | ||
|
|
a003b8836b | ||
|
|
df6003df94 | ||
|
|
b1d0fdbb02 | ||
|
|
2c34395110 | ||
|
|
534e4e5bf3 | ||
|
|
6146372eba | ||
|
|
aaa469a142 | ||
|
|
4fd5bf948e | ||
|
|
99f91fbce2 | ||
|
|
98a00ec7ac | ||
|
|
b0fb858c49 | ||
|
|
83959ef99e | ||
|
|
08087495d3 | ||
|
|
3f68474839 | ||
|
|
f26886f84d | ||
|
|
ddd50eac8e | ||
|
|
bba3e8d272 | ||
|
|
30944bfa18 | ||
|
|
8822de95df | ||
|
|
02a242fe4b | ||
|
|
1beac914db | ||
|
|
a45b42107e | ||
| 3d89b753f0 | |||
| fb77d99177 | |||
| fa627aabf9 | |||
|
|
fd2629862f | ||
|
|
75291f9397 | ||
|
|
99fb5f4b2b | ||
|
|
5dc3deeb11 | ||
|
|
6b708fcad3 | ||
|
|
bc0ff84d8d | ||
|
|
1ff6965dd2 | ||
|
|
d6fa877941 | ||
|
|
940f705f5d | ||
|
|
7a6e6c8bec | ||
|
|
67d200d817 | ||
|
|
a0c27ea8d3 | ||
|
|
3d583ff21d | ||
|
|
7072e282b1 | ||
|
|
145ac45036 | ||
|
|
698180ab7e | ||
|
|
0f4e38d51d | ||
|
|
e76283daa4 | ||
|
|
6ab42ca486 | ||
|
|
fa4da7624b | ||
|
|
9f5509d2d4 | ||
|
|
efaf38d303 | ||
|
|
95843fa4c7 | ||
|
|
5ba7661a83 | ||
|
|
ed5c58e10e | ||
|
|
5fce7d8f71 | ||
|
|
feeec4eb14 | ||
|
|
8c1941a87b | ||
|
|
765969db11 | ||
|
|
e60f17268d | ||
|
|
ce0a5e1229 | ||
|
|
c5738792b0 | ||
|
|
94e036dd10 | ||
|
|
da375b8086 | ||
|
|
7312763339 | ||
|
|
5005f203b8 | ||
|
|
232a73fd17 | ||
|
|
ef41691e40 | ||
|
|
3e6234e601 | ||
|
|
0a4b202428 | ||
|
|
a11893ece1 | ||
|
|
c5ad72c931 | ||
|
|
034f3173bd | ||
|
|
e2a6810e95 | ||
|
|
373d66f8af | ||
|
|
0b5f958f45 | ||
|
|
da899b90e2 | ||
|
|
2c4aa420b3 | ||
|
|
cd32912379 | ||
|
|
93dcb1753b | ||
|
|
35cf301905 | ||
|
|
5931fc1e71 | ||
|
|
18d845799c | ||
|
|
8c872bde92 | ||
|
|
f953472efd | ||
|
|
f10138b0f2 | ||
|
|
6f19d3d0ea | ||
|
|
a7facb005a | ||
|
|
88acf9df5d | ||
|
|
b0880b142a | ||
|
|
d3674c7f94 | ||
|
|
adccca6c7f | ||
|
|
8b83ccc4c2 | ||
|
|
556944b1d5 | ||
|
|
b14e07ee6e | ||
|
|
048bd9f78c | ||
|
|
d7e1aca7e3 | ||
|
|
de47d78a00 | ||
|
|
58b6f439b3 | ||
|
|
ce9bde5717 | ||
|
|
0cfe724ffa | ||
|
|
fde4bc051d | ||
|
|
367b0f1f89 | ||
|
|
d17a3dd590 | ||
|
|
bee5deed2a | ||
|
|
e6e46838b3 | ||
|
|
404f1ec059 | ||
|
|
09d36bc754 | ||
|
|
3722bbaec3 | ||
|
|
480ee4da83 | ||
|
|
dd853fe13b | ||
|
|
e1142a33a0 | ||
|
|
d4e8d91cae | ||
|
|
9a74ec545d | ||
|
|
f2000a1227 | ||
|
|
bf5767eadf | ||
|
|
e3f5206758 | ||
|
|
fffd21b348 | ||
|
|
2d74ef5e12 | ||
|
|
224743a439 | ||
|
|
f39a76da17 | ||
|
|
6107d02c8e | ||
|
|
1966b17f27 | ||
|
|
87c8aa5146 | ||
|
|
e4c027ad51 | ||
|
|
083355fdba | ||
|
|
a3b57f6e28 | ||
|
|
b0ffa145bc | ||
|
|
a8df5f4afd | ||
|
|
62de960e86 | ||
|
|
31532c0efa | ||
|
|
732230524d | ||
|
|
6dc281313e | ||
|
|
92db3c7c82 | ||
|
|
d8b75a47d3 | ||
|
|
d70fc1032f | ||
|
|
794ee8f6e0 | ||
|
|
43769e711d | ||
|
|
30528a1528 | ||
|
|
b7e1753d25 | ||
|
|
9c5772a303 | ||
|
|
7a3d710153 | ||
|
|
0a6ebe6e62 | ||
|
|
6cbc657da3 | ||
|
|
cd838915fd | ||
|
|
4e486fda69 | ||
|
|
79cac53fdb | ||
|
|
450d99f06e | ||
|
|
51ee8c0825 | ||
|
|
a1151606f2 | ||
|
|
38193c83dd | ||
|
|
59ecc36f2b | ||
|
|
8bc459c9a7 | ||
|
|
f1f1e47f76 | ||
|
|
679e9de245 | ||
|
|
f0ac996b3c | ||
|
|
2d77222ae8 | ||
|
|
e6e5a5fd64 | ||
|
|
e1eb04494a | ||
|
|
b7fa8e5f80 | ||
|
|
ef4fae4496 | ||
|
|
3e5a4e0555 | ||
|
|
cf9a8091ea | ||
|
|
0edc45dd0a | ||
|
|
ceca370e27 | ||
|
|
f235b9c2f9 | ||
|
|
d86c841f1f | ||
|
|
076b866c02 | ||
|
|
19d4430b31 | ||
|
|
e5be0e6789 | ||
|
|
27129a8921 | ||
|
|
da11c03d55 | ||
|
|
4fbdda0365 | ||
|
|
efacb1e916 | ||
|
|
d19ce253b4 | ||
|
|
e11a4b66e7 | ||
|
|
d0a1457f44 | ||
|
|
34e54934fd | ||
|
|
4873e6054f | ||
|
|
b00aa4e17b | ||
|
|
9fccfe6f35 | ||
|
|
43545fa04b |
84
.all-contributorsrc
Normal file
84
.all-contributorsrc
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
{
|
||||||
|
"projectName": "server",
|
||||||
|
"projectOwner": "sasjs",
|
||||||
|
"repoType": "github",
|
||||||
|
"repoHost": "https://github.com",
|
||||||
|
"files": [
|
||||||
|
"README.md"
|
||||||
|
],
|
||||||
|
"imageSize": 100,
|
||||||
|
"commit": true,
|
||||||
|
"commitConvention": "angular",
|
||||||
|
"contributors": [
|
||||||
|
{
|
||||||
|
"login": "saadjutt01",
|
||||||
|
"name": "Saad Jutt",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/8914650?v=4",
|
||||||
|
"profile": "https://github.com/saadjutt01",
|
||||||
|
"contributions": [
|
||||||
|
"code",
|
||||||
|
"test"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "sabhas",
|
||||||
|
"name": "Sabir Hassan",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/82647447?v=4",
|
||||||
|
"profile": "https://github.com/sabhas",
|
||||||
|
"contributions": [
|
||||||
|
"code",
|
||||||
|
"test"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "YuryShkoda",
|
||||||
|
"name": "Yury Shkoda",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/25773492?v=4",
|
||||||
|
"profile": "https://www.erudicat.com/",
|
||||||
|
"contributions": [
|
||||||
|
"code",
|
||||||
|
"test"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "medjedovicm",
|
||||||
|
"name": "Mihajlo Medjedovic",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/18329105?v=4",
|
||||||
|
"profile": "https://github.com/medjedovicm",
|
||||||
|
"contributions": [
|
||||||
|
"code",
|
||||||
|
"test"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "allanbowe",
|
||||||
|
"name": "Allan Bowe",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/4420615?v=4",
|
||||||
|
"profile": "https://4gl.io/",
|
||||||
|
"contributions": [
|
||||||
|
"code",
|
||||||
|
"doc"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "VladislavParhomchik",
|
||||||
|
"name": "Vladislav Parhomchik",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/83717836?v=4",
|
||||||
|
"profile": "https://github.com/VladislavParhomchik",
|
||||||
|
"contributions": [
|
||||||
|
"test"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "kknapen",
|
||||||
|
"name": "Koen Knapen",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/78609432?v=4",
|
||||||
|
"profile": "https://github.com/kknapen",
|
||||||
|
"contributions": [
|
||||||
|
"userTesting"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"contributorsPerLine": 7,
|
||||||
|
"skipCi": true
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
SAS_EXEC=<path to folder containing SAS executable 'sas'>
|
SAS_EXEC_PATH=<path to folder containing SAS executable>
|
||||||
|
SAS_EXEC_NAME=<name of SAS executable file>
|
||||||
PORT_API=<port for sasjs server (api)>
|
PORT_API=<port for sasjs server (api)>
|
||||||
PORT_WEB=<port for sasjs web component(react)>
|
PORT_WEB=<port for sasjs web component(react)>
|
||||||
ACCESS_TOKEN_SECRET=<secret>
|
ACCESS_TOKEN_SECRET=<secret>
|
||||||
|
|||||||
114
.github/CONTRIBUTING.md
vendored
Normal file
114
.github/CONTRIBUTING.md
vendored
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# CONTRIBUTING
|
||||||
|
|
||||||
|
Contributions are very welcome! Feel free to raise an issue or start a discussion, for help in getting started.
|
||||||
|
|
||||||
|
The app can be deployed using Docker or NodeJS.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Configuration is made using `.env` files (per [README.md](https://github.com/sasjs/server#env-var-configuration) settings), _except_ for one case, when running in NodeJS in production - in which case the path to the SAS executable is made in the `configuration` section of `package.json`.
|
||||||
|
|
||||||
|
The `.env` file should be created in the location(s) below. Each folder contains a `.env.example` file that may be adjusted and renamed.
|
||||||
|
|
||||||
|
* `.env` - the root .env file is used only for Docker deploys.
|
||||||
|
* `api/.env` - this is the primary file used in NodeJS deploys
|
||||||
|
* `web/.env` - this file is only necessary in NodeJS when running `web` and `api` seperately (on different ports).
|
||||||
|
|
||||||
|
|
||||||
|
## Using Docker
|
||||||
|
|
||||||
|
### Docker Development Mode
|
||||||
|
|
||||||
|
Command to run docker for development:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
It uses default docker compose file i.e. `docker-compose.yml` present at root.
|
||||||
|
It will build following images if running first time:
|
||||||
|
|
||||||
|
- `sasjs_server_api` - image for sasjs api server app based on _ExpressJS_
|
||||||
|
- `sasjs_server_web` - image for sasjs web component app based on _ReactJS_
|
||||||
|
- `mongodb` - image for mongo database
|
||||||
|
- `mongo-seed-users` - will be populating user data specified in _./mongo-seed/users/user.json_
|
||||||
|
- `mongo-seed-clients` - will be populating client data specified in _./mongo-seed/clients/client.json_
|
||||||
|
|
||||||
|
|
||||||
|
### Docker Production Mode
|
||||||
|
|
||||||
|
Command to run docker for production:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker-compose -f docker-compose.prod.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
It uses specified docker compose file i.e. `docker-compose.prod.yml` present at root.
|
||||||
|
It will build following images if running first time:
|
||||||
|
|
||||||
|
- `sasjs_server_prod` - image for sasjs server app containing api and web component's build served at route `/`
|
||||||
|
- `mongodb` - image for mongo database
|
||||||
|
- `mongo-seed-users` - will be populating user data specified in _./mongo-seed/users/user.json_
|
||||||
|
- `mongo-seed-clients` - will be populating client data specified in _./mongo-seed/clients/client.json_
|
||||||
|
|
||||||
|
## Using NodeJS:
|
||||||
|
|
||||||
|
Be sure to use v16 or above, and to set your environment variables in the relevant `.env` file(s) - else defaults will be used.
|
||||||
|
|
||||||
|
### NodeJS Development Mode
|
||||||
|
|
||||||
|
SASjs Server is split between an API server (serving REST requests) and a WEB Server (everything else). These can be run together, or on seperate ports.
|
||||||
|
|
||||||
|
### NodeJS Dev - Single Port
|
||||||
|
|
||||||
|
Here the environment variables should be configured under `api.env`. Then:
|
||||||
|
|
||||||
|
```
|
||||||
|
cd ./web && npm i && npm build
|
||||||
|
cd ../api && npm i && npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
### NodeJS Dev - Seperate Ports
|
||||||
|
|
||||||
|
Set the backend variables in `api/.env` and the frontend variables in `web/.env`. Then:
|
||||||
|
|
||||||
|
#### API server
|
||||||
|
```
|
||||||
|
cd api
|
||||||
|
npm install
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Web Server
|
||||||
|
|
||||||
|
```
|
||||||
|
cd web
|
||||||
|
npm install
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
#### NodeJS Production Mode
|
||||||
|
|
||||||
|
Update the `.env` file in the *api* folder. Then:
|
||||||
|
|
||||||
|
```
|
||||||
|
npm run server
|
||||||
|
```
|
||||||
|
|
||||||
|
This will install/build `web` and install `api`, then start prod server.
|
||||||
|
|
||||||
|
|
||||||
|
## Executables
|
||||||
|
|
||||||
|
In order to generate the final executables:
|
||||||
|
|
||||||
|
```
|
||||||
|
cd ./web && npm i && npm build && cd ../
|
||||||
|
cd ./api && npm i && npm run exe
|
||||||
|
```
|
||||||
|
|
||||||
|
This will install/build web app and install/create executables of sasjs server at root `./executables`
|
||||||
|
|
||||||
|
## Releases
|
||||||
|
|
||||||
|
To cut a release, run `npm run release` on the main branch, then push the tags (per the console log link)
|
||||||
3
.github/FUNDING.yml
vendored
Normal file
3
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
github: [sasjs]
|
||||||
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
@@ -54,6 +54,10 @@ jobs:
|
|||||||
ACCESS_TOKEN_SECRET: ${{secrets.ACCESS_TOKEN_SECRET}}
|
ACCESS_TOKEN_SECRET: ${{secrets.ACCESS_TOKEN_SECRET}}
|
||||||
REFRESH_TOKEN_SECRET: ${{secrets.REFRESH_TOKEN_SECRET}}
|
REFRESH_TOKEN_SECRET: ${{secrets.REFRESH_TOKEN_SECRET}}
|
||||||
AUTH_CODE_SECRET: ${{secrets.AUTH_CODE_SECRET}}
|
AUTH_CODE_SECRET: ${{secrets.AUTH_CODE_SECRET}}
|
||||||
|
SESSION_SECRET: ${{secrets.SESSION_SECRET}}
|
||||||
|
RUN_TIMES: 'sas,js'
|
||||||
|
SAS_PATH: '/some/path/to/sas'
|
||||||
|
NODE_PATH: '/some/path/to/node'
|
||||||
|
|
||||||
- name: Build Package
|
- name: Build Package
|
||||||
working-directory: ./api
|
working-directory: ./api
|
||||||
|
|||||||
34
.github/workflows/release.yml
vendored
34
.github/workflows/release.yml
vendored
@@ -2,16 +2,26 @@ name: SASjs Server Executable Release
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
branches:
|
||||||
- 'v*.*.*'
|
- main
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
node-version: [lts/*]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
|
- name: Use Node.js ${{ matrix.node-version }}
|
||||||
|
uses: actions/setup-node@v2
|
||||||
|
with:
|
||||||
|
node-version: ${{ matrix.node-version }}
|
||||||
|
|
||||||
- name: Install Dependencies WEB
|
- name: Install Dependencies WEB
|
||||||
working-directory: ./web
|
working-directory: ./web
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -32,10 +42,18 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
CI: true
|
CI: true
|
||||||
|
|
||||||
|
- name: Compress Executables
|
||||||
|
working-directory: ./executables
|
||||||
|
run: |
|
||||||
|
zip linux.zip api-linux
|
||||||
|
zip macos.zip api-macos
|
||||||
|
zip windows.zip api-win.exe
|
||||||
|
|
||||||
|
- name: Install Semantic Release and plugins
|
||||||
|
run: |
|
||||||
|
npm i
|
||||||
|
npm i -g semantic-release
|
||||||
|
|
||||||
- name: Release
|
- name: Release
|
||||||
uses: softprops/action-gh-release@v1
|
run: |
|
||||||
with:
|
GITHUB_TOKEN=${{ secrets.GH_TOKEN }} semantic-release
|
||||||
files: |
|
|
||||||
./executables/api-linux
|
|
||||||
./executables/api-macos
|
|
||||||
./executables/api-win.exe
|
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -4,9 +4,12 @@ node_modules/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.env*
|
.env*
|
||||||
sas/
|
sas/
|
||||||
|
sasjs_root/
|
||||||
tmp/
|
tmp/
|
||||||
build/
|
build/
|
||||||
sasjsbuild/
|
sasjsbuild/
|
||||||
|
sasjscore/
|
||||||
certificates/
|
certificates/
|
||||||
executables/
|
executables/
|
||||||
.env
|
.env
|
||||||
|
api/csp.config.json
|
||||||
|
|||||||
10
.gitpod.yml
Normal file
10
.gitpod.yml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# This configuration file was automatically generated by Gitpod.
|
||||||
|
# Please adjust to your needs (see https://www.gitpod.io/docs/config-gitpod-file)
|
||||||
|
# and commit this file to your remote git repository to share the goodness with others.
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
- init: npm install
|
||||||
|
vscode:
|
||||||
|
extensions:
|
||||||
|
- dbaeumer.vscode-eslint
|
||||||
|
- sasjs.sasjs-for-vscode
|
||||||
43
.releaserc
Normal file
43
.releaserc
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"branches": [
|
||||||
|
"main"
|
||||||
|
],
|
||||||
|
"plugins": [
|
||||||
|
"@semantic-release/commit-analyzer",
|
||||||
|
"@semantic-release/release-notes-generator",
|
||||||
|
"@semantic-release/changelog",
|
||||||
|
[
|
||||||
|
"@semantic-release/git",
|
||||||
|
{
|
||||||
|
"assets": [
|
||||||
|
"CHANGELOG.md"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@semantic-release/github",
|
||||||
|
{
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"path": "./executables/linux.zip",
|
||||||
|
"label": "Linux Executable Binary"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./executables/macos.zip",
|
||||||
|
"label": "Macos Executable Binary"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./executables/windows.zip",
|
||||||
|
"label": "Windows Executable Binary"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@semantic-release/exec",
|
||||||
|
{
|
||||||
|
"publishCmd": "echo 'publish command'"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
1419
CHANGELOG.md
1419
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,4 @@
|
|||||||
FROM node:lts-alpine
|
FROM node:lts-alpine
|
||||||
RUN npm install -g @sasjs/cli
|
|
||||||
WORKDIR /usr/server/api
|
WORKDIR /usr/server/api
|
||||||
COPY ["package.json","package-lock.json", "./"]
|
COPY ["package.json","package-lock.json", "./"]
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2022 SASjs
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
19
PULL_REQUEST_TEMPLATE.md
Normal file
19
PULL_REQUEST_TEMPLATE.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
## Issue
|
||||||
|
|
||||||
|
Link any related issue(s) in this section.
|
||||||
|
|
||||||
|
## Intent
|
||||||
|
|
||||||
|
What this PR intends to achieve.
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
What code changes have been made to achieve the intent.
|
||||||
|
|
||||||
|
## Checks
|
||||||
|
|
||||||
|
- [ ] Code is formatted correctly (`npm run lint:fix`).
|
||||||
|
- [ ] Any new functionality has been unit tested.
|
||||||
|
- [ ] All unit tests are passing (`npm test`).
|
||||||
|
- [ ] All CI checks are green.
|
||||||
|
- [ ] Reviewer is assigned.
|
||||||
308
README.md
308
README.md
@@ -1,117 +1,279 @@
|
|||||||
# SASjs Server
|
# SASjs Server
|
||||||
|
|
||||||
SASjs Server provides a NodeJS wrapper for calling the SAS binary executable. It can be installed on an actual SAS server, or it could even run locally on your desktop. It provides the following functionality:
|
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
||||||
|
|
||||||
|
[](#contributors-)
|
||||||
|
|
||||||
|
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||||
|
|
||||||
|
SASjs Server provides a NodeJS wrapper for calling the SAS binary executable. It can be installed on an actual SAS server, or locally on your desktop. It provides:
|
||||||
|
|
||||||
- Virtual filesystem for storing SAS programs and other content
|
- Virtual filesystem for storing SAS programs and other content
|
||||||
- Ability to execute Stored Programs from a URL
|
- Ability to execute Stored Programs from a URL
|
||||||
- Ability to create web apps using simple Desktop SAS
|
- Ability to create web apps using simple Desktop SAS
|
||||||
|
- REST API with Swagger Docs
|
||||||
|
|
||||||
One major benefit of using SASjs Server (alongside other components of the SASjs framework such as the [CLI](https://cli.sasjs.io), [Adapter](https://adapter.sasjs.io) and [Core](https://core.sasjs.io) library) is that the projects you create can be very easily ported to SAS 9 (Stored Process server) or Viya (Job Execution server).
|
One major benefit of using SASjs Server alongside other components of the SASjs framework such as the [CLI](https://cli.sasjs.io), [Adapter](https://adapter.sasjs.io) and [Core](https://core.sasjs.io) library, is that the projects you create can be very easily ported to SAS 9 (Stored Process server) or Viya (Job Execution server).
|
||||||
|
|
||||||
## Configuration
|
SASjs Server is available in two modes - Desktop (without authentication) and Server (with authentication, and a database)
|
||||||
|
|
||||||
Configuration is made in the `configuration` section of `package.json`:
|
## Installation
|
||||||
|
|
||||||
- Provide path to SAS9 executable.
|
Installation can be made programmatically using command line, or by manually downloading and running the executable.
|
||||||
|
|
||||||
### Using dockers:
|
### Programmatic
|
||||||
|
|
||||||
There is `.env.example` file present at root of the project. [for Production]
|
Fetch the relevant package from github using `curl`, eg as follows (for linux):
|
||||||
|
|
||||||
There is `.env.example` file present at `./api` of the project. [for Development]
|
```bash
|
||||||
|
curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
|
||||||
There is `.env.example` file present at `./web` of the project. [for Development]
|
unzip linux.zip
|
||||||
|
|
||||||
Remember to provide enviornment variables.
|
|
||||||
|
|
||||||
#### Development
|
|
||||||
|
|
||||||
Command to run docker for development:
|
|
||||||
|
|
||||||
```
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
```
|
||||||
|
|
||||||
It uses default docker compose file i.e. `docker-compose.yml` present at root.
|
The app can then be launched with `./api-linux` and prompts followed (if ENV vars not set).
|
||||||
It will build following images if running first time:
|
|
||||||
|
|
||||||
- `sasjs_server_api` - image for sasjs api server app based on _ExpressJS_
|
### Manual
|
||||||
- `sasjs_server_web` - image for sasjs web component app based on _ReactJS_
|
|
||||||
- `mongodb` - image for mongo database
|
|
||||||
- `mongo-seed-users` - will be populating user data specified in _./mongo-seed/users/user.json_
|
|
||||||
- `mongo-seed-clients` - will be populating client data specified in _./mongo-seed/clients/client.json_
|
|
||||||
|
|
||||||
#### Production
|
1. Download the relevant package from the [releases](https://github.com/sasjs/server/releases) page
|
||||||
|
2. Trigger by double clicking (windows) or executing from commandline.
|
||||||
|
|
||||||
Command to run docker for production:
|
You are presented with two prompts (if not set as ENV vars):
|
||||||
|
|
||||||
|
- Location of your `sas.exe` / `sas.sh` executable
|
||||||
|
- Path to a filesystem location for Stored Programs and temporary files
|
||||||
|
|
||||||
|
## ENV Var configuration
|
||||||
|
|
||||||
|
When launching the app, it will make use of specific environment variables. These can be set in the following places:
|
||||||
|
|
||||||
|
- Configured globally in `/etc/environment` file
|
||||||
|
- Export in terminal or shell script (`export VAR=VALUE`)
|
||||||
|
- Prepended in the command
|
||||||
|
- Enter in the `.env` file alongside the executable
|
||||||
|
|
||||||
|
Example contents of a `.env` file:
|
||||||
|
|
||||||
```
|
```
|
||||||
docker-compose -f docker-compose.prod.yml up -d
|
#
|
||||||
```
|
## Core Settings
|
||||||
|
#
|
||||||
|
|
||||||
It uses specified docker compose file i.e. `docker-compose.prod.yml` present at root.
|
|
||||||
It will build following images if running first time:
|
|
||||||
|
|
||||||
- `sasjs_server_prod` - image for sasjs server app containing api and web component's build served at route `/`
|
# MODE options: [desktop|server] default: `desktop`
|
||||||
- `mongodb` - image for mongo database
|
# Desktop mode is single user and designed for workstation use
|
||||||
- `mongo-seed-users` - will be populating user data specified in _./mongo-seed/users/user.json_
|
# Server mode is multi-user and suitable for intranet / internet use
|
||||||
- `mongo-seed-clients` - will be populating client data specified in _./mongo-seed/clients/client.json_
|
MODE=
|
||||||
|
|
||||||
### Using node:
|
# A comma separated string that defines the available runTimes.
|
||||||
|
# Priority is given to the runtime that comes first in the string.
|
||||||
|
# Possible options at the moment are sas, js, py and r
|
||||||
|
|
||||||
#### Development (running api and web seperately):
|
# This string sets the priority of the available analytic runtimes
|
||||||
|
# Valid runtimes are SAS (sas), JavaScript (js), Python (py) and R (r)
|
||||||
|
# For each option provided, there should be a corresponding path,
|
||||||
|
# eg SAS_PATH, NODE_PATH, PYTHON_PATH or RSCRIPT_PATH
|
||||||
|
# Priority is given to runtimes earlier in the string
|
||||||
|
# Example options: [sas,js,py | js,py | sas | sas,js | r | sas,r]
|
||||||
|
RUN_TIMES=
|
||||||
|
|
||||||
##### API
|
# Path to SAS executable (sas.exe / sas.sh)
|
||||||
|
SAS_PATH=/path/to/sas/executable.exe
|
||||||
|
|
||||||
Navigate to `./api`
|
# Path to Node.js executable
|
||||||
There is `.env.example` file present at `./api` directory. Remember to provide enviornment variables else default values will be used mentioned in `.env.example` files
|
NODE_PATH=~/.nvm/versions/node/v16.14.0/bin/node
|
||||||
Command to install and run api server.
|
|
||||||
|
# Path to Python executable
|
||||||
|
PYTHON_PATH=/usr/bin/python
|
||||||
|
|
||||||
|
# Path to R executable
|
||||||
|
R_PATH=/usr/bin/Rscript
|
||||||
|
|
||||||
|
# Path to working directory
|
||||||
|
# This location is for SAS WORK, staged files, DRIVE, configuration etc
|
||||||
|
SASJS_ROOT=./sasjs_root
|
||||||
|
|
||||||
|
|
||||||
|
# This location is for files, sasjs packages and appStreamConfig.json
|
||||||
|
DRIVE_LOCATION=./sasjs_root/drive
|
||||||
|
|
||||||
|
|
||||||
|
# options: [http|https] default: http
|
||||||
|
PROTOCOL=
|
||||||
|
|
||||||
|
# default: 5000
|
||||||
|
PORT=
|
||||||
|
|
||||||
|
# options: [sas9|sasviya]
|
||||||
|
# If not present, mocking function is disabled
|
||||||
|
MOCK_SERVERTYPE=
|
||||||
|
|
||||||
|
# default: /api/mocks
|
||||||
|
# Path to mocking folder, for generic responses, it's sub directories should be: sas9, viya, sasjs
|
||||||
|
# Server will automatically use subdirectory accordingly
|
||||||
|
STATIC_MOCK_LOCATION=
|
||||||
|
|
||||||
|
#
|
||||||
|
## Additional SAS Options
|
||||||
|
#
|
||||||
|
|
||||||
|
|
||||||
|
# On windows use SAS_OPTIONS and on unix use SASV9_OPTIONS
|
||||||
|
# Any options set here are automatically applied in the SAS session
|
||||||
|
# See: https://documentation.sas.com/doc/en/pgmsascdc/9.4_3.5/hostunx/p0wrdmqp8k0oyyn1xbx3bp3qy2wl.htm
|
||||||
|
# And: https://documentation.sas.com/doc/en/pgmsascdc/9.4_3.5/hostwin/p0drw76qo0gig2n1kcoliekh605k.htm#p09y7hx0grw1gin1giuvrjyx61m6
|
||||||
|
SAS_OPTIONS= -NOXCMD
|
||||||
|
SASV9_OPTIONS= -NOXCMD
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
## Additional Web Server Options
|
||||||
|
#
|
||||||
|
|
||||||
|
# ENV variables for PROTOCOL: `https`
|
||||||
|
PRIVATE_KEY=privkey.pem (required)
|
||||||
|
CERT_CHAIN=certificate.pem (required)
|
||||||
|
CA_ROOT=fullchain.pem (optional)
|
||||||
|
|
||||||
|
## ENV variables required for MODE: `server`
|
||||||
|
DB_CONNECT=mongodb+srv://<DB_USERNAME>:<DB_PASSWORD>@<CLUSTER>/<DB_NAME>?retryWrites=true&w=majority
|
||||||
|
|
||||||
|
# options: [mongodb|cosmos_mongodb] default: mongodb
|
||||||
|
DB_TYPE=
|
||||||
|
|
||||||
|
# AUTH_PROVIDERS options: [ldap] default: ``
|
||||||
|
AUTH_PROVIDERS=
|
||||||
|
|
||||||
|
## ENV variables required for AUTH_MECHANISM: `ldap`
|
||||||
|
LDAP_URL= <LDAP_SERVER_URL>
|
||||||
|
LDAP_BIND_DN= <cn=admin,ou=system,dc=cloudron>
|
||||||
|
LDAP_BIND_PASSWORD = <password>
|
||||||
|
LDAP_USERS_BASE_DN = <ou=users,dc=cloudron>
|
||||||
|
LDAP_GROUPS_BASE_DN = <ou=groups,dc=cloudron>
|
||||||
|
|
||||||
|
# options: [disable|enable] default: `disable` for `server` & `enable` for `desktop`
|
||||||
|
# If enabled, be sure to also configure the WHITELIST of third party servers.
|
||||||
|
CORS=
|
||||||
|
|
||||||
|
# options: <http://localhost:3000 https://abc.com ...> space separated urls
|
||||||
|
WHITELIST=
|
||||||
|
|
||||||
|
# HELMET Cross Origin Embedder Policy
|
||||||
|
# Sets the Cross-Origin-Embedder-Policy header to require-corp when `true`
|
||||||
|
# options: [true|false] default: true
|
||||||
|
# Docs: https://helmetjs.github.io/#reference (`crossOriginEmbedderPolicy`)
|
||||||
|
HELMET_COEP=
|
||||||
|
|
||||||
|
# HELMET Content Security Policy
|
||||||
|
# Path to a json file containing HELMET `contentSecurityPolicy` directives
|
||||||
|
# Docs: https://helmetjs.github.io/#reference
|
||||||
|
#
|
||||||
|
# Example config:
|
||||||
|
# {
|
||||||
|
# "img-src": ["'self'", "data:"],
|
||||||
|
# "script-src": ["'self'", "'unsafe-inline'"],
|
||||||
|
# "script-src-attr": ["'self'", "'unsafe-inline'"]
|
||||||
|
# }
|
||||||
|
HELMET_CSP_CONFIG_PATH=./csp.config.json
|
||||||
|
|
||||||
|
# To prevent brute force attack on login route we have implemented rate limiter
|
||||||
|
# Only valid for MODE: server
|
||||||
|
# Following are configurable env variable rate limiter
|
||||||
|
|
||||||
|
# After this, access is blocked for 1 day
|
||||||
|
MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY = <number> default: 100;
|
||||||
|
|
||||||
|
|
||||||
|
# After this, access is blocked for an hour
|
||||||
|
# Store number for 90 days since first fail
|
||||||
|
# Once a successful login is attempted, it resets
|
||||||
|
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP = <number> default: 10;
|
||||||
|
|
||||||
|
# LOG_FORMAT_MORGAN options: [combined|common|dev|short|tiny] default: `common`
|
||||||
|
# Docs: https://www.npmjs.com/package/morgan#predefined-formats
|
||||||
|
LOG_FORMAT_MORGAN=
|
||||||
|
|
||||||
|
# This location is for server logs with classical UNIX logrotate behavior
|
||||||
|
LOG_LOCATION=./sasjs_root/logs
|
||||||
|
|
||||||
```
|
```
|
||||||
npm install
|
|
||||||
npm start
|
## Persisting the Session
|
||||||
|
|
||||||
|
Normally the server process will stop when your terminal dies. To keep it going you can use the following suggested approaches:
|
||||||
|
|
||||||
|
1. Linux Background Job
|
||||||
|
2. NPM package `pm2`
|
||||||
|
|
||||||
|
### Background Job
|
||||||
|
|
||||||
|
Trigger the command using NOHUP, redirecting the output commands, eg `nohup ./api-linux > server.log 2>&1 &`.
|
||||||
|
|
||||||
|
You can now see the job running using the `jobs` command. To ensure that it will still run when your terminal is closed, execute the `disown` command. To kill it later, use the `kill -9 <pid>` command. You can see your sessions using `top -u <userid>`. Type `c` to see the commands being run against each pid.
|
||||||
|
|
||||||
|
### PM2
|
||||||
|
|
||||||
|
Install the npm package [pm2](https://www.npmjs.com/package/pm2) (`npm install pm2@latest -g`) and execute, eg as follows:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export SAS_PATH=/opt/sas9/SASHome/SASFoundation/9.4/sasexe/sas
|
||||||
|
export PORT=5001
|
||||||
|
export SASJS_ROOT=./sasjs_root
|
||||||
|
|
||||||
|
pm2 start api-linux
|
||||||
```
|
```
|
||||||
|
|
||||||
##### Web
|
To get the logs (and some useful commands):
|
||||||
|
|
||||||
Navigate to `./web`
|
```bash
|
||||||
There is `.env.example` file present at `./web` directory. Remember to provide enviornment variables else default values will be used mentioned in `.env.example` files
|
pm2 [list|ls|status]
|
||||||
Command to install and run api server.
|
pm2 logs
|
||||||
|
pm2 logs --lines 200
|
||||||
```
|
|
||||||
npm install
|
|
||||||
npm start
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Development (running only api server and have web build served):
|
Managing processes:
|
||||||
|
|
||||||
##### API server also serving Web build files
|
|
||||||
|
|
||||||
There is `.env.example` file present at `./api` directory. Remember to provide enviornment variables else default values will be used mentioned in `.env.example` files
|
|
||||||
Command to install and run api server.
|
|
||||||
|
|
||||||
```
|
```
|
||||||
cd ./web && npm i && npm build && cd ../
|
pm2 restart app_name
|
||||||
cd ./api && npm i && npm start
|
pm2 reload app_name
|
||||||
|
pm2 stop app_name
|
||||||
|
pm2 delete app_name
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Production
|
Instead of `app_name` you can pass:
|
||||||
|
|
||||||
##### API & WEB
|
- `all` to act on all processes
|
||||||
|
- `id` to act on a specific process id
|
||||||
|
|
||||||
```
|
## Server Version
|
||||||
npm run server
|
|
||||||
```
|
|
||||||
|
|
||||||
This will install/build `web` and install `api`, then start prod server.
|
The following credentials can be used for the initial connection to SASjs/server. It is highly recommended to change these on first use.
|
||||||
|
|
||||||
## Executables
|
- CLIENTID: `clientID1`
|
||||||
|
- USERNAME: `secretuser`
|
||||||
|
- PASSWORD: `secretpassword`
|
||||||
|
|
||||||
Command to generate executables
|
## Contributors ✨
|
||||||
|
|
||||||
```
|
Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
|
||||||
cd ./web && npm i && npm build && cd ../
|
|
||||||
cd ./api && npm i && npm run exe
|
|
||||||
```
|
|
||||||
|
|
||||||
This will install/build web app and install/create executables of sasjs server at root `./executables`
|
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
|
||||||
|
<!-- prettier-ignore-start -->
|
||||||
|
<!-- markdownlint-disable -->
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><a href="https://github.com/saadjutt01"><img src="https://avatars.githubusercontent.com/u/8914650?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Saad Jutt</b></sub></a><br /><a href="https://github.com/sasjs/server/commits?author=saadjutt01" title="Code">💻</a> <a href="https://github.com/sasjs/server/commits?author=saadjutt01" title="Tests">⚠️</a></td>
|
||||||
|
<td align="center"><a href="https://github.com/sabhas"><img src="https://avatars.githubusercontent.com/u/82647447?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Sabir Hassan</b></sub></a><br /><a href="https://github.com/sasjs/server/commits?author=sabhas" title="Code">💻</a> <a href="https://github.com/sasjs/server/commits?author=sabhas" title="Tests">⚠️</a></td>
|
||||||
|
<td align="center"><a href="https://www.erudicat.com/"><img src="https://avatars.githubusercontent.com/u/25773492?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Yury Shkoda</b></sub></a><br /><a href="https://github.com/sasjs/server/commits?author=YuryShkoda" title="Code">💻</a> <a href="https://github.com/sasjs/server/commits?author=YuryShkoda" title="Tests">⚠️</a></td>
|
||||||
|
<td align="center"><a href="https://github.com/medjedovicm"><img src="https://avatars.githubusercontent.com/u/18329105?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Mihajlo Medjedovic</b></sub></a><br /><a href="https://github.com/sasjs/server/commits?author=medjedovicm" title="Code">💻</a> <a href="https://github.com/sasjs/server/commits?author=medjedovicm" title="Tests">⚠️</a></td>
|
||||||
|
<td align="center"><a href="https://4gl.io/"><img src="https://avatars.githubusercontent.com/u/4420615?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Allan Bowe</b></sub></a><br /><a href="https://github.com/sasjs/server/commits?author=allanbowe" title="Code">💻</a> <a href="https://github.com/sasjs/server/commits?author=allanbowe" title="Documentation">📖</a></td>
|
||||||
|
<td align="center"><a href="https://github.com/VladislavParhomchik"><img src="https://avatars.githubusercontent.com/u/83717836?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Vladislav Parhomchik</b></sub></a><br /><a href="https://github.com/sasjs/server/commits?author=VladislavParhomchik" title="Tests">⚠️</a></td>
|
||||||
|
<td align="center"><a href="https://github.com/kknapen"><img src="https://avatars.githubusercontent.com/u/78609432?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Koen Knapen</b></sub></a><br /><a href="#userTesting-kknapen" title="User Testing">📓</a></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- markdownlint-restore -->
|
||||||
|
<!-- prettier-ignore-end -->
|
||||||
|
|
||||||
|
<!-- ALL-CONTRIBUTORS-LIST:END -->
|
||||||
|
|
||||||
|
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
|
||||||
|
|||||||
89
SASjsServer.drawio
Normal file
89
SASjsServer.drawio
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<mxfile host="65bd71144e">
|
||||||
|
<diagram id="HJy_QFGaI9JSrArARLup" name="Page-1">
|
||||||
|
<mxGraphModel dx="1908" dy="2140" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" math="0" shadow="0">
|
||||||
|
<root>
|
||||||
|
<mxCell id="0"/>
|
||||||
|
<mxCell id="1" parent="0"/>
|
||||||
|
<mxCell id="4" value="End user" style="shape=umlActor;verticalLabelPosition=bottom;verticalAlign=top;html=1;outlineConnect=0;fontStyle=0" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="-360" y="-120" width="40" height="80" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="7" value="SASjs Server" style="whiteSpace=wrap;html=1;verticalAlign=top;fontStyle=0;fontSize=30;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="30" y="-150" width="360" height="850" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="8" value="" style="edgeStyle=none;html=1;entryX=0;entryY=0.25;entryDx=0;entryDy=0;" edge="1" parent="1" target="28">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<mxPoint x="-340" y="23" as="sourcePoint"/>
|
||||||
|
<mxPoint x="115" y="22.586363636363558" as="targetPoint"/>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="11" value="<div style="font-family: &#34;menlo&#34; , &#34;monaco&#34; , &#34;courier new&#34; , monospace ; font-size: 12px ; line-height: 18px"><span style="color: #a31515">/SASjsApi/auth/authorize<br>(username,password,clientId)</span></div>" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" vertex="1" connectable="0" parent="8">
|
||||||
|
<mxGeometry x="-0.1257" y="2" relative="1" as="geometry">
|
||||||
|
<mxPoint as="offset"/>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="14" value="" style="edgeStyle=none;html=1;exitX=-0.002;exitY=0.874;exitDx=0;exitDy=0;exitPerimeter=0;" edge="1" parent="1" source="28">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<mxPoint x="110" y="80" as="sourcePoint"/>
|
||||||
|
<mxPoint x="-340" y="80" as="targetPoint"/>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="16" value="<font color="#a31515" face="menlo, monaco, courier new, monospace"><span style="font-size: 12px">`code`</span></font>" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" vertex="1" connectable="0" parent="14">
|
||||||
|
<mxGeometry x="0.1931" y="-1" relative="1" as="geometry">
|
||||||
|
<mxPoint as="offset"/>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="21" value="End user" style="shape=umlActor;verticalLabelPosition=bottom;verticalAlign=top;html=1;outlineConnect=0;fontStyle=0" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="-360" y="545" width="40" height="80" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="22" value="" style="edgeStyle=none;html=1;entryX=0;entryY=0.25;entryDx=0;entryDy=0;" edge="1" parent="1" target="30">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<mxPoint x="-340" y="165" as="sourcePoint"/>
|
||||||
|
<mxPoint x="115" y="165" as="targetPoint"/>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="23" value="<div style="font-family: &#34;menlo&#34; , &#34;monaco&#34; , &#34;courier new&#34; , monospace ; font-size: 12px ; line-height: 18px"><div style="font-family: &#34;menlo&#34; , &#34;monaco&#34; , &#34;courier new&#34; , monospace ; line-height: 18px"><span style="color: #a31515">/SASjsApi/auth/token</span></div><span style="color: #a31515">(clientId,code)</span></div>" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" vertex="1" connectable="0" parent="22">
|
||||||
|
<mxGeometry x="-0.1257" y="2" relative="1" as="geometry">
|
||||||
|
<mxPoint as="offset"/>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="24" value="" style="edgeStyle=none;html=1;exitX=0.009;exitY=0.905;exitDx=0;exitDy=0;exitPerimeter=0;" edge="1" parent="1" source="30">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<mxPoint x="210" y="222.5" as="sourcePoint"/>
|
||||||
|
<mxPoint x="-340" y="223" as="targetPoint"/>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="25" value="<font color="#a31515" face="menlo, monaco, courier new, monospace"><span style="font-size: 12px">`</span></font><span style="color: rgb(163 , 21 , 21) ; font-family: &#34;menlo&#34; , &#34;monaco&#34; , &#34;courier new&#34; , monospace ; font-size: 12px">accessToken</span><span style="font-size: 12px ; color: rgb(163 , 21 , 21) ; font-family: &#34;menlo&#34; , &#34;monaco&#34; , &#34;courier new&#34; , monospace">` &amp; `</span><span style="color: rgb(163 , 21 , 21) ; font-family: &#34;menlo&#34; , &#34;monaco&#34; , &#34;courier new&#34; , monospace ; font-size: 12px">refreshToken</span><span style="color: rgb(163 , 21 , 21) ; font-family: &#34;menlo&#34; , &#34;monaco&#34; , &#34;courier new&#34; , monospace ; font-size: 12px">`</span>" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" vertex="1" connectable="0" parent="24">
|
||||||
|
<mxGeometry x="0.1931" y="-1" relative="1" as="geometry">
|
||||||
|
<mxPoint as="offset"/>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="26" value="" style="endArrow=none;dashed=1;html=1;dashPattern=1 3;strokeWidth=2;" edge="1" parent="1" source="21" target="4">
|
||||||
|
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||||
|
<mxPoint x="40" y="240" as="sourcePoint"/>
|
||||||
|
<mxPoint x="90" y="190" as="targetPoint"/>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="28" value="<span>Validates</span><br><span>username/password/clientId</span><br><span>and issue short</span><br><span>Authorization code</span>" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="115" width="190" height="90" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="30" value="Validates<br>clientId &amp; authorization code<br>and issue<br>Access Token &amp; Refresh Token" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="115" y="140" width="190" height="90" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="32" value="Protected APIs<br>Authenticate requests <br>with provided Bearer Token" style="whiteSpace=wrap;html=1;verticalAlign=top;fontStyle=0;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="50" y="280" width="320" height="400" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="33" value="" style="edgeStyle=none;html=1;entryX=0;entryY=0.373;entryDx=0;entryDy=0;entryPerimeter=0;" edge="1" parent="1" target="32">
|
||||||
|
<mxGeometry relative="1" as="geometry">
|
||||||
|
<mxPoint x="-340" y="432.5" as="sourcePoint"/>
|
||||||
|
<mxPoint x="-10" y="430" as="targetPoint"/>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="34" value="<div style="font-family: &#34;menlo&#34; , &#34;monaco&#34; , &#34;courier new&#34; , monospace ; font-size: 12px ; line-height: 18px"><div style="font-family: &#34;menlo&#34; , &#34;monaco&#34; , &#34;courier new&#34; , monospace ; line-height: 18px"><font color="#a31515">Request with Access Token</font></div></div>" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" vertex="1" connectable="0" parent="33">
|
||||||
|
<mxGeometry x="-0.1257" y="2" relative="1" as="geometry">
|
||||||
|
<mxPoint as="offset"/>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
</root>
|
||||||
|
</mxGraphModel>
|
||||||
|
</diagram>
|
||||||
|
</mxfile>
|
||||||
@@ -1,8 +1,43 @@
|
|||||||
MODE=[desktop|server] default considered as desktop
|
MODE=[desktop|server] default considered as desktop
|
||||||
CORS=[disable|enable] default considered as disable
|
CORS=[disable|enable] default considered as disable for server MODE & enable for desktop MODE
|
||||||
|
ALLOWED_DOMAIN=<just domain e.g. example.com >
|
||||||
|
WHITELIST=<space separated urls, each starting with protocol `http` or `https`>
|
||||||
|
|
||||||
|
PROTOCOL=[http|https] default considered as http
|
||||||
|
PRIVATE_KEY=privkey.pem
|
||||||
|
CERT_CHAIN=certificate.pem
|
||||||
|
CA_ROOT=fullchain.pem
|
||||||
|
|
||||||
PORT=[5000] default value is 5000
|
PORT=[5000] default value is 5000
|
||||||
PORT_WEB=[port for sasjs web component(react)] default value is 3000
|
|
||||||
ACCESS_TOKEN_SECRET=<secret>
|
HELMET_CSP_CONFIG_PATH=./csp.config.json if omitted HELMET default will be used
|
||||||
REFRESH_TOKEN_SECRET=<secret>
|
HELMET_COEP=[true|false] if omitted HELMET default will be used
|
||||||
AUTH_CODE_SECRET=<secret>
|
|
||||||
DB_CONNECT=mongodb+srv://<DB_USERNAME>:<DB_PASSWORD>@<CLUSTER>/<DB_NAME>?retryWrites=true&w=majority
|
DB_CONNECT=mongodb+srv://<DB_USERNAME>:<DB_PASSWORD>@<CLUSTER>/<DB_NAME>?retryWrites=true&w=majority
|
||||||
|
DB_TYPE=[mongodb|cosmos_mongodb] default considered as mongodb
|
||||||
|
|
||||||
|
AUTH_PROVIDERS=[ldap]
|
||||||
|
|
||||||
|
LDAP_URL= <LDAP_SERVER_URL>
|
||||||
|
LDAP_BIND_DN= <cn=admin,ou=system,dc=cloudron>
|
||||||
|
LDAP_BIND_PASSWORD = <password>
|
||||||
|
LDAP_USERS_BASE_DN = <ou=users,dc=cloudron>
|
||||||
|
LDAP_GROUPS_BASE_DN = <ou=groups,dc=cloudron>
|
||||||
|
|
||||||
|
#default value is 100
|
||||||
|
MAX_WRONG_ATTEMPTS_BY_IP_PER_DAY=100
|
||||||
|
|
||||||
|
#default value is 10
|
||||||
|
MAX_CONSECUTIVE_FAILS_BY_USERNAME_AND_IP=10
|
||||||
|
|
||||||
|
RUN_TIMES=[sas,js,py | js,py | sas | sas,js] default considered as sas
|
||||||
|
SAS_PATH=/opt/sas/sas9/SASHome/SASFoundation/9.4/sas
|
||||||
|
NODE_PATH=~/.nvm/versions/node/v16.14.0/bin/node
|
||||||
|
PYTHON_PATH=/usr/bin/python
|
||||||
|
R_PATH=/usr/bin/Rscript
|
||||||
|
|
||||||
|
SASJS_ROOT=./sasjs_root
|
||||||
|
DRIVE_LOCATION=./sasjs_root/drive
|
||||||
|
|
||||||
|
LOG_FORMAT_MORGAN=common
|
||||||
|
LOG_LOCATION=./sasjs_root/logs
|
||||||
1
api/.nvmrc
Normal file
1
api/.nvmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
v16.15.1
|
||||||
13
api/.vscode/launch.json
vendored
Normal file
13
api/.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Launch via NPM",
|
||||||
|
"request": "launch",
|
||||||
|
"runtimeArgs": ["run-script", "start"],
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"skipFiles": ["<node_internals>/**"],
|
||||||
|
"type": "pwa-node"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
5
api/csp.config.example.json
Normal file
5
api/csp.config.example.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"img-src": ["'self'", "data:"],
|
||||||
|
"script-src": ["'self'", "'unsafe-inline'"],
|
||||||
|
"script-src-attr": ["'self'", "'unsafe-inline'"]
|
||||||
|
}
|
||||||
1
api/mocks/sas9/generic/logged-in
Normal file
1
api/mocks/sas9/generic/logged-in
Normal file
@@ -0,0 +1 @@
|
|||||||
|
You have signed in.
|
||||||
1
api/mocks/sas9/generic/logged-out
Normal file
1
api/mocks/sas9/generic/logged-out
Normal file
@@ -0,0 +1 @@
|
|||||||
|
You have signed out.
|
||||||
30
api/mocks/sas9/generic/login
Normal file
30
api/mocks/sas9/generic/login
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" lang="en-US" dir="ltr" class="bg">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="initial-scale=1" />
|
||||||
|
</head>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<form id="credentials" class="minimal" action="/SASLogon/login?service=http%3A%2F%2Flocalhost:5004%2FSASStoredProcess%2Fj_spring_cas_security_check" method="post">
|
||||||
|
<!--form container-->
|
||||||
|
<input type="hidden" name="lt" value="validtoken" aria-hidden="true" />
|
||||||
|
<input type="hidden" name="execution" value="e2s1" aria-hidden="true" />
|
||||||
|
<input type="hidden" name="_eventId" value="submit" aria-hidden="true" />
|
||||||
|
|
||||||
|
<span class="userid">
|
||||||
|
|
||||||
|
<input id="username" name="username" tabindex="3" aria-labelledby="username1 message1 message2 message3" name="username" placeholder="User ID" type="text" autofocus="true" value="" maxlength="500" autocomplete="off" />
|
||||||
|
</span>
|
||||||
|
<span class="password">
|
||||||
|
|
||||||
|
<input id="password" name="password" tabindex="4" name="password" placeholder="Password" type="password" value="" maxlength="500" autocomplete="off" />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button type="submit" class="btn-submit" title="Sign In" tabindex="5" onClick="this.disabled=true;setSubmitUrl(this.form);this.form.submit();return false;">Sign In</button>
|
||||||
|
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</html>
|
||||||
1
api/mocks/sas9/generic/public-access-denied
Normal file
1
api/mocks/sas9/generic/public-access-denied
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Public access has been denied.
|
||||||
1
api/mocks/sas9/generic/sas-stored-process
Normal file
1
api/mocks/sas9/generic/sas-stored-process
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"title": "Log Off SAS Demo User"
|
||||||
15385
api/package-lock.json
generated
15385
api/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,35 +1,37 @@
|
|||||||
{
|
{
|
||||||
"name": "api",
|
"name": "api",
|
||||||
"version": "0.0.1",
|
"version": "0.0.2",
|
||||||
"description": "Api of SASjs server",
|
"description": "Api of SASjs server",
|
||||||
"main": "./src/server.ts",
|
"main": "./src/server.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"initial": "npm run swagger && npm run compileSysInit",
|
"initial": "npm run swagger && npm run compileSysInit && npm run copySASjsCore && npm run downloadMacros",
|
||||||
"prestart": "npm run initial",
|
"prestart": "npm run initial",
|
||||||
"prestart:prod": "npm run initial",
|
|
||||||
"prebuild": "npm run initial",
|
"prebuild": "npm run initial",
|
||||||
"start": "nodemon ./src/server.ts",
|
"start": "NODE_ENV=development nodemon ./src/server.ts",
|
||||||
"start:prod": "nodemon ./src/prod-server.ts",
|
"start:prod": "node ./build/src/server.js",
|
||||||
"build": "rimraf build && tsc",
|
"build": "rimraf build && tsc",
|
||||||
|
"postbuild": "npm run copy:files",
|
||||||
"swagger": "tsoa spec",
|
"swagger": "tsoa spec",
|
||||||
"semantic-release": "semantic-release -d",
|
|
||||||
"prepare": "[ -d .git ] && git config core.hooksPath ./.git-hooks || true",
|
"prepare": "[ -d .git ] && git config core.hooksPath ./.git-hooks || true",
|
||||||
"test": "mkdir -p tmp && mkdir -p ../web/build && jest --silent --coverage",
|
"test": "mkdir -p tmp && mkdir -p ../web/build && jest --silent --coverage",
|
||||||
"lint:fix": "npx prettier --write \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
|
"lint:fix": "npx prettier --write \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
|
||||||
"lint": "npx prettier --check \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
|
"lint": "npx prettier --check \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
|
||||||
"package:lib": "npm run build && cp ./package.json build && cp README.md build && cd build && npm version \"5.0.0\" && npm pack",
|
"exe": "npm run build && pkg .",
|
||||||
"exe": "npm run build && npm run exe:copy && pkg .",
|
"copy:files": "npm run public:copy && npm run sasjsbuild:copy && npm run sas:copy && npm run web:copy",
|
||||||
"exe:copy": "npm run public:copy && npm run sasjsbuild:copy && npm run web:copy",
|
|
||||||
"public:copy": "cp -r ./public/ ./build/public/",
|
"public:copy": "cp -r ./public/ ./build/public/",
|
||||||
"sasjsbuild:copy": "cp -r ./sasjsbuild/ ./build/sasjsbuild/",
|
"sasjsbuild:copy": "cp -r ./sasjsbuild/ ./build/sasjsbuild/",
|
||||||
|
"sas:copy": "cp -r ./sas/ ./build/sas/",
|
||||||
"web:copy": "rimraf web && mkdir web && cp -r ../web/build/ ./web/build/",
|
"web:copy": "rimraf web && mkdir web && cp -r ../web/build/ ./web/build/",
|
||||||
"compileSysInit": "ts-node ./scripts/compileSysInit.ts"
|
"compileSysInit": "ts-node ./scripts/compileSysInit.ts",
|
||||||
|
"copySASjsCore": "ts-node ./scripts/copySASjsCore.ts",
|
||||||
|
"downloadMacros": "ts-node ./scripts/downloadMacros.ts"
|
||||||
},
|
},
|
||||||
"bin": "./build/src/server.js",
|
"bin": "./build/src/server.js",
|
||||||
"pkg": {
|
"pkg": {
|
||||||
"assets": [
|
"assets": [
|
||||||
"./build/public/**/*",
|
"./build/public/**/*",
|
||||||
"./build/sasjsbuild/**/*",
|
"./build/sasjsbuild/**/*",
|
||||||
|
"./build/sas/**/*",
|
||||||
"./web/build/**/*"
|
"./web/build/**/*"
|
||||||
],
|
],
|
||||||
"targets": [
|
"targets": [
|
||||||
@@ -41,51 +43,71 @@
|
|||||||
},
|
},
|
||||||
"release": {
|
"release": {
|
||||||
"branches": [
|
"branches": [
|
||||||
"master"
|
"main"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"author": "Analytium Ltd",
|
"author": "4GL Ltd",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sasjs/core": "^2.48.6",
|
"@sasjs/core": "^4.40.1",
|
||||||
"@sasjs/utils": "2.34.1",
|
"@sasjs/utils": "3.2.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
|
"connect-mongo": "^4.6.0",
|
||||||
|
"cookie-parser": "^1.4.6",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.17.1",
|
"express": "^4.17.1",
|
||||||
|
"express-session": "^1.17.2",
|
||||||
|
"helmet": "^5.0.2",
|
||||||
"joi": "^17.4.2",
|
"joi": "^17.4.2",
|
||||||
"jsonwebtoken": "^8.5.1",
|
"jsonwebtoken": "^8.5.1",
|
||||||
|
"ldapjs": "2.3.3",
|
||||||
"mongoose": "^6.0.12",
|
"mongoose": "^6.0.12",
|
||||||
"mongoose-sequence": "^5.3.1",
|
"mongoose-sequence": "^5.3.1",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"multer": "^1.4.3",
|
"multer": "^1.4.5-lts.1",
|
||||||
"swagger-ui-express": "^4.1.6",
|
"rate-limiter-flexible": "2.4.1",
|
||||||
"tsoa": "^3.14.0"
|
"rotating-file-stream": "^3.0.4",
|
||||||
|
"swagger-ui-express": "4.3.0",
|
||||||
|
"unzipper": "^0.10.11",
|
||||||
|
"url": "^0.10.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/adm-zip": "^0.5.0",
|
||||||
"@types/bcryptjs": "^2.4.2",
|
"@types/bcryptjs": "^2.4.2",
|
||||||
|
"@types/cookie-parser": "^1.4.2",
|
||||||
"@types/cors": "^2.8.12",
|
"@types/cors": "^2.8.12",
|
||||||
"@types/express": "^4.17.12",
|
"@types/express": "^4.17.12",
|
||||||
|
"@types/express-session": "^1.17.4",
|
||||||
"@types/jest": "^26.0.24",
|
"@types/jest": "^26.0.24",
|
||||||
"@types/jsonwebtoken": "^8.5.5",
|
"@types/jsonwebtoken": "^8.5.5",
|
||||||
|
"@types/ldapjs": "^2.2.4",
|
||||||
"@types/mongoose-sequence": "^3.0.6",
|
"@types/mongoose-sequence": "^3.0.6",
|
||||||
"@types/morgan": "^1.9.3",
|
"@types/morgan": "^1.9.3",
|
||||||
"@types/multer": "^1.4.7",
|
"@types/multer": "^1.4.7",
|
||||||
"@types/node": "^15.12.2",
|
"@types/node": "^15.12.2",
|
||||||
"@types/supertest": "^2.0.11",
|
"@types/supertest": "^2.0.11",
|
||||||
"@types/swagger-ui-express": "^4.1.3",
|
"@types/swagger-ui-express": "^4.1.3",
|
||||||
|
"@types/unzipper": "^0.10.5",
|
||||||
|
"adm-zip": "^0.5.9",
|
||||||
|
"axios": "0.27.2",
|
||||||
|
"csrf": "^3.1.0",
|
||||||
"dotenv": "^10.0.0",
|
"dotenv": "^10.0.0",
|
||||||
|
"http-headers-validation": "^0.0.1",
|
||||||
"jest": "^27.0.6",
|
"jest": "^27.0.6",
|
||||||
"mongodb-memory-server": "^8.0.0",
|
"mongodb-memory-server": "8.11.4",
|
||||||
|
"nodejs-file-downloader": "4.10.2",
|
||||||
"nodemon": "^2.0.7",
|
"nodemon": "^2.0.7",
|
||||||
"pkg": "^5.4.1",
|
"pkg": "5.6.0",
|
||||||
"prettier": "^2.3.1",
|
"prettier": "^2.3.1",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"semantic-release": "^17.4.3",
|
|
||||||
"supertest": "^6.1.3",
|
"supertest": "^6.1.3",
|
||||||
"ts-jest": "^27.0.3",
|
"ts-jest": "^27.0.3",
|
||||||
"ts-node": "^10.0.0",
|
"ts-node": "^10.0.0",
|
||||||
|
"tsoa": "3.14.1",
|
||||||
"typescript": "^4.3.2"
|
"typescript": "^4.3.2"
|
||||||
},
|
},
|
||||||
"configuration": {
|
"nodemonConfig": {
|
||||||
"sasPath": "/opt/sas/sas9/SASHome/SASFoundation/9.4"
|
"ignore": [
|
||||||
|
"sasjs_root/**/*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
50
api/public/SASjsApi/swagger-ui-init.js
Normal file
50
api/public/SASjsApi/swagger-ui-init.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
window.onload = function () {
|
||||||
|
// Build a system
|
||||||
|
var url = window.location.search.match(/url=([^&]+)/)
|
||||||
|
if (url && url.length > 1) {
|
||||||
|
url = decodeURIComponent(url[1])
|
||||||
|
} else {
|
||||||
|
url = window.location.origin
|
||||||
|
}
|
||||||
|
var options = {
|
||||||
|
customOptions: {
|
||||||
|
url: '/swagger.yaml',
|
||||||
|
requestInterceptor: function (request) {
|
||||||
|
request.credentials = 'include'
|
||||||
|
var cookie = document.cookie
|
||||||
|
var startIndex = cookie.indexOf('XSRF-TOKEN')
|
||||||
|
var csrf = cookie.slice(startIndex + 11).split('; ')[0]
|
||||||
|
request.headers['X-XSRF-TOKEN'] = csrf
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
url = options.swaggerUrl || url
|
||||||
|
var urls = options.swaggerUrls
|
||||||
|
var customOptions = options.customOptions
|
||||||
|
var spec1 = options.swaggerDoc
|
||||||
|
var swaggerOptions = {
|
||||||
|
spec: spec1,
|
||||||
|
url: url,
|
||||||
|
urls: urls,
|
||||||
|
dom_id: '#swagger-ui',
|
||||||
|
deepLinking: true,
|
||||||
|
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
|
||||||
|
plugins: [SwaggerUIBundle.plugins.DownloadUrl],
|
||||||
|
layout: 'StandaloneLayout'
|
||||||
|
}
|
||||||
|
for (var attrname in customOptions) {
|
||||||
|
swaggerOptions[attrname] = customOptions[attrname]
|
||||||
|
}
|
||||||
|
var ui = SwaggerUIBundle(swaggerOptions)
|
||||||
|
|
||||||
|
if (customOptions.oauth) {
|
||||||
|
ui.initOAuth(customOptions.oauth)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customOptions.authAction) {
|
||||||
|
ui.authActions.authorize(customOptions.authAction)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.ui = ui
|
||||||
|
}
|
||||||
49
api/public/app-streams-script.js
Normal file
49
api/public/app-streams-script.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
const inputElement = document.getElementById('fileId')
|
||||||
|
|
||||||
|
document.getElementById('uploadButton').addEventListener('click', function () {
|
||||||
|
inputElement.click()
|
||||||
|
})
|
||||||
|
|
||||||
|
inputElement.addEventListener(
|
||||||
|
'change',
|
||||||
|
function () {
|
||||||
|
const fileList = this.files /* now you can work with the file list */
|
||||||
|
|
||||||
|
updateFileUploadMessage('Requesting ...')
|
||||||
|
|
||||||
|
const file = fileList[0]
|
||||||
|
const formData = new FormData()
|
||||||
|
|
||||||
|
formData.append('file', file)
|
||||||
|
|
||||||
|
axios
|
||||||
|
.post('/SASjsApi/drive/deploy/upload', formData)
|
||||||
|
.then((res) => res.data)
|
||||||
|
.then((data) => {
|
||||||
|
return (
|
||||||
|
data.message +
|
||||||
|
'\nstreamServiceName: ' +
|
||||||
|
data.streamServiceName +
|
||||||
|
'\nrefreshing page once alert box closes.'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.then((message) => {
|
||||||
|
alert(message)
|
||||||
|
location.reload()
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
alert(error.response.data)
|
||||||
|
resetFileUpload()
|
||||||
|
updateFileUploadMessage('Upload New App')
|
||||||
|
})
|
||||||
|
},
|
||||||
|
false
|
||||||
|
)
|
||||||
|
|
||||||
|
function updateFileUploadMessage(message) {
|
||||||
|
document.getElementById('uploadMessage').innerHTML = message
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFileUpload() {
|
||||||
|
inputElement.value = null
|
||||||
|
}
|
||||||
3
api/public/axios.min.js
vendored
Normal file
3
api/public/axios.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
api/public/plus.png
Normal file
BIN
api/public/plus.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 899 B |
21
api/public/sasjs-logo.svg
Normal file
21
api/public/sasjs-logo.svg
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
viewBox="0 0 32 32" style="enable-background:new 0 0 32 32;" xml:space="preserve">
|
||||||
|
<style type="text/css">
|
||||||
|
.st0{fill:#F6E40C;}
|
||||||
|
</style>
|
||||||
|
<rect id="XMLID_1_" width="32" height="32"/>
|
||||||
|
<g id="XMLID_654_">
|
||||||
|
<path id="XMLID_656_" class="st0" d="M27.9,17.4c-1.1,0-2.1,0-3,0c-1.2,0-2.3,0-3.5,0c-0.5,0-0.7,0.2-0.6,0.7c0,2.1,0,4.3,0,6.4
|
||||||
|
c0,0.5-0.2,0.8-0.6,1c-2.5,1.4-4.9,2.8-7.3,4.3c-0.4,0.2-0.6,0.2-1,0c-2.4-1.4-4.9-2.9-7.3-4.3c-0.2-0.1-0.5-0.5-0.5-0.7
|
||||||
|
c0-3.2,0-6.4,0-9.6c0-0.1,0-0.1,0.1-0.3c0.3,0,0.5,0,0.8,0c1.9,0,3.7,0,5.6,0c0.6,0,0.7-0.2,0.7-0.7c0-2.1,0-4.2,0-6.4
|
||||||
|
c0-0.5,0.1-0.8,0.6-1.1c2.5-1.4,4.9-2.9,7.3-4.3c0.2-0.1,0.6-0.1,0.9,0c2.5,1.4,5,2.9,7.5,4.4c0.2,0.1,0.4,0.4,0.4,0.6
|
||||||
|
C27.9,10.6,27.9,13.9,27.9,17.4z M20.8,14.8c1.4,0,2.7,0,4,0c0.5,0,0.7-0.2,0.7-0.7c0-1.7,0-3.3,0-5c0-0.5-0.2-0.7-0.6-1
|
||||||
|
c-1.6-0.9-3.2-1.9-4.8-2.8c-0.2-0.1-0.7-0.1-0.9,0c-1.6,0.9-3.2,1.9-4.8,2.8c-0.4,0.2-0.6,0.5-0.6,1c0,3.2,0,6.3,0,9.5
|
||||||
|
c0,1.9,0,1.9-1.9,1.9c-0.4,0-0.6-0.1-0.6-0.6c0-0.6,0-1.3,0-1.9c0-0.5-0.2-0.6-0.6-0.6c-1.1,0-2.2,0-3.3,0c-0.5,0-0.7,0.2-0.7,0.7
|
||||||
|
c0,1.6,0,3.3,0,4.9c0,0.5,0.2,0.8,0.6,1c1.6,0.9,3.2,1.9,4.8,2.8c0.2,0.1,0.7,0.1,0.9,0c1.6-0.9,3.2-1.9,4.8-2.8
|
||||||
|
c0.4-0.2,0.6-0.5,0.6-1c0-3.1,0-6.1,0-9.2c0-1.9,0-1.9,1.9-1.9c0.5,0,0.7,0.2,0.7,0.7C20.8,13.3,20.8,14,20.8,14.8z"/>
|
||||||
|
<path id="XMLID_655_" class="st0" d="M18,2.1l-6.8,3.9V2.7c0-0.3,0.3-0.6,0.6-0.6H18z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
File diff suppressed because it is too large
Load Diff
@@ -1,27 +1,29 @@
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import {
|
import {
|
||||||
|
CompileTree,
|
||||||
createFile,
|
createFile,
|
||||||
loadDependenciesFile,
|
loadDependenciesFile,
|
||||||
readFile,
|
readFile,
|
||||||
SASJsFileType
|
SASJsFileType
|
||||||
} from '@sasjs/utils'
|
} from '@sasjs/utils'
|
||||||
import { apiRoot, sysInitCompiledPath } from '../src/utils'
|
import { apiRoot, sysInitCompiledPath } from '../src/utils/file'
|
||||||
|
|
||||||
const macroCorePath = path.join(apiRoot, 'node_modules', '@sasjs', 'core')
|
const macroCorePath = path.join(apiRoot, 'node_modules', '@sasjs', 'core')
|
||||||
|
|
||||||
const compiledSystemInit = async (systemInit: string) =>
|
const compiledSystemInit = async (systemInit: string) =>
|
||||||
'options ps=max;\n' +
|
'options ls=max ps=max;\n' +
|
||||||
(await loadDependenciesFile({
|
(await loadDependenciesFile({
|
||||||
fileContent: systemInit,
|
fileContent: systemInit,
|
||||||
type: SASJsFileType.job,
|
type: SASJsFileType.job,
|
||||||
programFolders: [],
|
programFolders: [],
|
||||||
macroFolders: [],
|
macroFolders: [],
|
||||||
buildSourceFolder: '',
|
buildSourceFolder: '',
|
||||||
macroCorePath
|
binaryFolders: [],
|
||||||
|
macroCorePath,
|
||||||
|
compileTree: new CompileTree('') // dummy compileTree
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const createSysInitFile = async () => {
|
const createSysInitFile = async () => {
|
||||||
console.log('macroCorePath', macroCorePath)
|
|
||||||
const systemInitContent = await readFile(
|
const systemInitContent = await readFile(
|
||||||
path.join(__dirname, 'systemInit.sas')
|
path.join(__dirname, 'systemInit.sas')
|
||||||
)
|
)
|
||||||
|
|||||||
36
api/scripts/copySASjsCore.ts
Normal file
36
api/scripts/copySASjsCore.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import path from 'path'
|
||||||
|
import {
|
||||||
|
asyncForEach,
|
||||||
|
copy,
|
||||||
|
createFile,
|
||||||
|
createFolder,
|
||||||
|
deleteFolder,
|
||||||
|
listFilesInFolder
|
||||||
|
} from '@sasjs/utils'
|
||||||
|
|
||||||
|
import {
|
||||||
|
apiRoot,
|
||||||
|
sasJSCoreMacros,
|
||||||
|
sasJSCoreMacrosInfo
|
||||||
|
} from '../src/utils/file'
|
||||||
|
|
||||||
|
const macroCorePath = path.join(apiRoot, 'node_modules', '@sasjs', 'core')
|
||||||
|
|
||||||
|
export const copySASjsCore = async () => {
|
||||||
|
await deleteFolder(sasJSCoreMacros)
|
||||||
|
await createFolder(sasJSCoreMacros)
|
||||||
|
|
||||||
|
const foldersToCopy = ['base', 'ddl', 'fcmp', 'lua', 'server']
|
||||||
|
|
||||||
|
await asyncForEach(foldersToCopy, async (coreSubFolder) => {
|
||||||
|
const coreSubFolderPath = path.join(macroCorePath, coreSubFolder)
|
||||||
|
|
||||||
|
await copy(coreSubFolderPath, sasJSCoreMacros)
|
||||||
|
})
|
||||||
|
|
||||||
|
const fileNames = await listFilesInFolder(sasJSCoreMacros)
|
||||||
|
|
||||||
|
await createFile(sasJSCoreMacrosInfo, fileNames.join('\n'))
|
||||||
|
}
|
||||||
|
|
||||||
|
copySASjsCore()
|
||||||
39
api/scripts/downloadMacros.ts
Normal file
39
api/scripts/downloadMacros.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import Downloader from 'nodejs-file-downloader'
|
||||||
|
import { createFile, listFilesInFolder } from '@sasjs/utils'
|
||||||
|
|
||||||
|
import { sasJSCoreMacros, sasJSCoreMacrosInfo } from '../src/utils/file'
|
||||||
|
|
||||||
|
export const downloadMacros = async () => {
|
||||||
|
const url =
|
||||||
|
'https://api.github.com/repos/yabwon/SAS_PACKAGES/contents/SPF/Macros'
|
||||||
|
|
||||||
|
console.info(`Downloading macros from ${url}`)
|
||||||
|
|
||||||
|
await axios
|
||||||
|
.get(url)
|
||||||
|
.then(async (res) => {
|
||||||
|
await downloadFiles(res.data)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
throw new Error(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadFiles = async function (fileList: any) {
|
||||||
|
for (const file of fileList) {
|
||||||
|
const downloader = new Downloader({
|
||||||
|
url: file.download_url,
|
||||||
|
directory: sasJSCoreMacros,
|
||||||
|
fileName: file.path.replace(/^SPF\/Macros/, ''),
|
||||||
|
cloneFiles: false
|
||||||
|
})
|
||||||
|
await downloader.download()
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileNames = await listFilesInFolder(sasJSCoreMacros)
|
||||||
|
|
||||||
|
await createFile(sasJSCoreMacrosInfo, fileNames.join('\n'))
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadMacros()
|
||||||
@@ -4,10 +4,13 @@
|
|||||||
@details This program is inserted into every sasjs/server program invocation,
|
@details This program is inserted into every sasjs/server program invocation,
|
||||||
_before_ any user-provided content.
|
_before_ any user-provided content.
|
||||||
|
|
||||||
<h4> SAS Macros </h4>
|
A number of useful CORE macros are also compiled below, so that they can be
|
||||||
@li mcf_stpsrv_header.sas
|
available by default for Stored Programs.
|
||||||
|
|
||||||
|
Note that the full CORE library is available to sessions in SASjs Studio.
|
||||||
|
|
||||||
|
<h4> SAS Macros </h4>
|
||||||
|
@li mfs_httpheader.sas
|
||||||
|
@li ms_webout.sas
|
||||||
**/
|
**/
|
||||||
|
|
||||||
|
|
||||||
%mcf_stpsrv_header(wrap=YES, insert_cmplib=YES)
|
|
||||||
|
|||||||
21
api/src/app-modules/configureCors.ts
Normal file
21
api/src/app-modules/configureCors.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Express } from 'express'
|
||||||
|
import cors from 'cors'
|
||||||
|
import { CorsType } from '../utils'
|
||||||
|
|
||||||
|
export const configureCors = (app: Express) => {
|
||||||
|
const { CORS, WHITELIST } = process.env
|
||||||
|
|
||||||
|
if (CORS === CorsType.ENABLED) {
|
||||||
|
const whiteList: string[] = []
|
||||||
|
WHITELIST?.split(' ')
|
||||||
|
?.filter((url) => !!url)
|
||||||
|
.forEach((url) => {
|
||||||
|
if (url.startsWith('http'))
|
||||||
|
// removing trailing slash of URLs listing for CORS
|
||||||
|
whiteList.push(url.replace(/\/$/, ''))
|
||||||
|
})
|
||||||
|
|
||||||
|
process.logger.info('All CORS Requests are enabled for:', whiteList)
|
||||||
|
app.use(cors({ credentials: true, origin: whiteList }))
|
||||||
|
}
|
||||||
|
}
|
||||||
48
api/src/app-modules/configureExpressSession.ts
Normal file
48
api/src/app-modules/configureExpressSession.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { Express, CookieOptions } from 'express'
|
||||||
|
import mongoose from 'mongoose'
|
||||||
|
import session from 'express-session'
|
||||||
|
import MongoStore from 'connect-mongo'
|
||||||
|
|
||||||
|
import { DatabaseType, ModeType, ProtocolType } from '../utils'
|
||||||
|
|
||||||
|
export const configureExpressSession = (app: Express) => {
|
||||||
|
const { MODE, DB_TYPE } = process.env
|
||||||
|
|
||||||
|
if (MODE === ModeType.Server) {
|
||||||
|
let store: MongoStore | undefined
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'test') {
|
||||||
|
if (DB_TYPE === DatabaseType.COSMOS_MONGODB) {
|
||||||
|
// COSMOS DB requires specific connection options (compatibility mode)
|
||||||
|
// See: https://www.npmjs.com/package/connect-mongo#set-the-compatibility-mode
|
||||||
|
store = MongoStore.create({
|
||||||
|
client: mongoose.connection!.getClient() as any,
|
||||||
|
autoRemove: 'interval'
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
store = MongoStore.create({
|
||||||
|
client: mongoose.connection!.getClient() as any
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { PROTOCOL, ALLOWED_DOMAIN } = process.env
|
||||||
|
const cookieOptions: CookieOptions = {
|
||||||
|
secure: PROTOCOL === ProtocolType.HTTPS,
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: PROTOCOL === ProtocolType.HTTPS ? 'none' : undefined,
|
||||||
|
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
||||||
|
domain: ALLOWED_DOMAIN?.trim() || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
session({
|
||||||
|
secret: process.secrets.SESSION_SECRET,
|
||||||
|
saveUninitialized: false, // don't create session until something stored
|
||||||
|
resave: false, //don't save session if unmodified
|
||||||
|
store,
|
||||||
|
cookie: cookieOptions
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
33
api/src/app-modules/configureLogger.ts
Normal file
33
api/src/app-modules/configureLogger.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import path from 'path'
|
||||||
|
import { Express } from 'express'
|
||||||
|
import morgan from 'morgan'
|
||||||
|
import { createStream } from 'rotating-file-stream'
|
||||||
|
import { generateTimestamp } from '@sasjs/utils'
|
||||||
|
import { getLogFolder } from '../utils'
|
||||||
|
|
||||||
|
export const configureLogger = (app: Express) => {
|
||||||
|
const { LOG_FORMAT_MORGAN } = process.env
|
||||||
|
|
||||||
|
let options
|
||||||
|
if (
|
||||||
|
process.env.NODE_ENV !== 'development' &&
|
||||||
|
process.env.NODE_ENV !== 'test'
|
||||||
|
) {
|
||||||
|
const timestamp = generateTimestamp()
|
||||||
|
const filename = `${timestamp}.log`
|
||||||
|
const logsFolder = getLogFolder()
|
||||||
|
|
||||||
|
// create a rotating write stream
|
||||||
|
var accessLogStream = createStream(filename, {
|
||||||
|
interval: '1d', // rotate daily
|
||||||
|
path: logsFolder
|
||||||
|
})
|
||||||
|
|
||||||
|
process.logger.info('Writing Logs to :', path.join(logsFolder, filename))
|
||||||
|
|
||||||
|
options = { stream: accessLogStream }
|
||||||
|
}
|
||||||
|
|
||||||
|
// setup the logger
|
||||||
|
app.use(morgan(LOG_FORMAT_MORGAN as string, options))
|
||||||
|
}
|
||||||
26
api/src/app-modules/configureSecurity.ts
Normal file
26
api/src/app-modules/configureSecurity.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { Express } from 'express'
|
||||||
|
import { getEnvCSPDirectives } from '../utils/parseHelmetConfig'
|
||||||
|
import { HelmetCoepType, ProtocolType } from '../utils'
|
||||||
|
import helmet from 'helmet'
|
||||||
|
|
||||||
|
export const configureSecurity = (app: Express) => {
|
||||||
|
const { PROTOCOL, HELMET_CSP_CONFIG_PATH, HELMET_COEP } = process.env
|
||||||
|
|
||||||
|
const cspConfigJson: { [key: string]: string[] | null } = getEnvCSPDirectives(
|
||||||
|
HELMET_CSP_CONFIG_PATH
|
||||||
|
)
|
||||||
|
if (PROTOCOL === ProtocolType.HTTP)
|
||||||
|
cspConfigJson['upgrade-insecure-requests'] = null
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
helmet({
|
||||||
|
contentSecurityPolicy: {
|
||||||
|
directives: {
|
||||||
|
...helmet.contentSecurityPolicy.getDefaultDirectives(),
|
||||||
|
...cspConfigJson
|
||||||
|
}
|
||||||
|
},
|
||||||
|
crossOriginEmbedderPolicy: HELMET_COEP === HelmetCoepType.TRUE
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
4
api/src/app-modules/index.ts
Normal file
4
api/src/app-modules/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './configureCors'
|
||||||
|
export * from './configureExpressSession'
|
||||||
|
export * from './configureLogger'
|
||||||
|
export * from './configureSecurity'
|
||||||
108
api/src/app.ts
108
api/src/app.ts
@@ -1,33 +1,101 @@
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import express from 'express'
|
import express, { ErrorRequestHandler } from 'express'
|
||||||
import morgan from 'morgan'
|
import cookieParser from 'cookie-parser'
|
||||||
import dotenv from 'dotenv'
|
import dotenv from 'dotenv'
|
||||||
import cors from 'cors'
|
|
||||||
|
|
||||||
import webRouter from './routes/web'
|
import {
|
||||||
import apiRouter from './routes/api'
|
copySASjsCore,
|
||||||
import { connectDB, getWebBuildFolderPath } from './utils'
|
createWeboutSasFile,
|
||||||
|
getFilesFolder,
|
||||||
|
getPackagesFolder,
|
||||||
|
getWebBuildFolder,
|
||||||
|
instantiateLogger,
|
||||||
|
loadAppStreamConfig,
|
||||||
|
ReturnCode,
|
||||||
|
setProcessVariables,
|
||||||
|
setupFilesFolder,
|
||||||
|
setupPackagesFolder,
|
||||||
|
setupUserAutoExec,
|
||||||
|
verifyEnvVariables
|
||||||
|
} from './utils'
|
||||||
|
import {
|
||||||
|
configureCors,
|
||||||
|
configureExpressSession,
|
||||||
|
configureLogger,
|
||||||
|
configureSecurity
|
||||||
|
} from './app-modules'
|
||||||
|
import { folderExists } from '@sasjs/utils'
|
||||||
|
|
||||||
dotenv.config()
|
dotenv.config()
|
||||||
|
|
||||||
|
instantiateLogger()
|
||||||
|
|
||||||
|
if (verifyEnvVariables()) process.exit(ReturnCode.InvalidEnv)
|
||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
|
|
||||||
const { MODE, CORS, PORT_WEB } = process.env
|
const onError: ErrorRequestHandler = (err, req, res, next) => {
|
||||||
if (MODE?.trim() !== 'server' || CORS?.trim() === 'enable') {
|
process.logger.error(err.stack)
|
||||||
console.log('All CORS Requests are enabled')
|
res.status(500).send('Something broke!')
|
||||||
app.use(
|
|
||||||
cors({ credentials: true, origin: `http://localhost:${PORT_WEB ?? 3000}` })
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
app.use(express.json({ limit: '50mb' }))
|
export default setProcessVariables().then(async () => {
|
||||||
app.use(morgan('tiny'))
|
app.use(cookieParser())
|
||||||
app.use(express.static(path.join(__dirname, '../public')))
|
|
||||||
|
|
||||||
app.use('/', webRouter)
|
configureLogger(app)
|
||||||
app.use('/SASjsApi', apiRouter)
|
|
||||||
app.use(express.json({ limit: '50mb' }))
|
|
||||||
|
|
||||||
app.use(express.static(getWebBuildFolderPath()))
|
/***********************************
|
||||||
|
* Handle security and origin *
|
||||||
|
***********************************/
|
||||||
|
configureSecurity(app)
|
||||||
|
|
||||||
export default connectDB().then(() => app)
|
/***********************************
|
||||||
|
* Enabling CORS *
|
||||||
|
***********************************/
|
||||||
|
configureCors(app)
|
||||||
|
|
||||||
|
/***********************************
|
||||||
|
* DB Connection & *
|
||||||
|
* Express Sessions *
|
||||||
|
* With Mongo Store *
|
||||||
|
***********************************/
|
||||||
|
configureExpressSession(app)
|
||||||
|
|
||||||
|
app.use(express.json({ limit: '100mb' }))
|
||||||
|
app.use(express.static(path.join(__dirname, '../public')))
|
||||||
|
|
||||||
|
// Body parser is used for decoding the formdata on POST request.
|
||||||
|
// Currently only place we use it is SAS9 Mock - POST /SASLogon/login
|
||||||
|
app.use(express.urlencoded({ extended: true }))
|
||||||
|
|
||||||
|
await setupUserAutoExec()
|
||||||
|
|
||||||
|
if (!(await folderExists(getFilesFolder()))) await setupFilesFolder()
|
||||||
|
|
||||||
|
if (!(await folderExists(getPackagesFolder()))) await setupPackagesFolder()
|
||||||
|
|
||||||
|
const sasautosPath = path.join(process.driveLoc, 'sas', 'sasautos')
|
||||||
|
if (await folderExists(sasautosPath)) {
|
||||||
|
process.logger.warn(
|
||||||
|
`SASAUTOS was not refreshed. To force a refresh, delete the ${sasautosPath} folder`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
await copySASjsCore()
|
||||||
|
await createWeboutSasFile()
|
||||||
|
}
|
||||||
|
|
||||||
|
// loading these modules after setting up variables due to
|
||||||
|
// multer's usage of process var process.driveLoc
|
||||||
|
const { setupRoutes } = await import('./routes/setupRoutes')
|
||||||
|
setupRoutes(app)
|
||||||
|
|
||||||
|
await loadAppStreamConfig()
|
||||||
|
|
||||||
|
// should be served after setting up web route
|
||||||
|
// index.html needs to be injected with some js script.
|
||||||
|
app.use(express.static(getWebBuildFolder()))
|
||||||
|
|
||||||
|
app.use(onError)
|
||||||
|
|
||||||
|
return app
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,14 +1,27 @@
|
|||||||
import { Security, Route, Tags, Example, Post, Body, Query, Hidden } from 'tsoa'
|
import express from 'express'
|
||||||
|
import {
|
||||||
|
Security,
|
||||||
|
Route,
|
||||||
|
Tags,
|
||||||
|
Example,
|
||||||
|
Post,
|
||||||
|
Patch,
|
||||||
|
Request,
|
||||||
|
Body,
|
||||||
|
Query,
|
||||||
|
Hidden
|
||||||
|
} from 'tsoa'
|
||||||
import jwt from 'jsonwebtoken'
|
import jwt from 'jsonwebtoken'
|
||||||
import User from '../model/User'
|
|
||||||
import { InfoJWT } from '../types'
|
import { InfoJWT } from '../types'
|
||||||
import {
|
import {
|
||||||
generateAccessToken,
|
generateAccessToken,
|
||||||
generateAuthCode,
|
|
||||||
generateRefreshToken,
|
generateRefreshToken,
|
||||||
|
getTokensFromDB,
|
||||||
removeTokensInDB,
|
removeTokensInDB,
|
||||||
saveTokensInDB
|
saveTokensInDB
|
||||||
} from '../utils'
|
} from '../utils'
|
||||||
|
import Client from '../model/Client'
|
||||||
|
import User from '../model/User'
|
||||||
|
|
||||||
@Route('SASjsApi/auth')
|
@Route('SASjsApi/auth')
|
||||||
@Tags('Auth')
|
@Tags('Auth')
|
||||||
@@ -24,20 +37,6 @@ export class AuthController {
|
|||||||
static deleteCode = (userId: number, clientId: string) =>
|
static deleteCode = (userId: number, clientId: string) =>
|
||||||
delete AuthController.authCodes[userId][clientId]
|
delete AuthController.authCodes[userId][clientId]
|
||||||
|
|
||||||
/**
|
|
||||||
* @summary Accept a valid username/password, plus a CLIENT_ID, and return an AUTH_CODE
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
@Example<AuthorizeResponse>({
|
|
||||||
code: 'someRandomCryptoString'
|
|
||||||
})
|
|
||||||
@Post('/authorize')
|
|
||||||
public async authorize(
|
|
||||||
@Body() body: AuthorizePayload
|
|
||||||
): Promise<AuthorizeResponse> {
|
|
||||||
return authorize(body)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary Accepts client/auth code and returns access/refresh tokens
|
* @summary Accepts client/auth code and returns access/refresh tokens
|
||||||
*
|
*
|
||||||
@@ -76,30 +75,18 @@ export class AuthController {
|
|||||||
public async logout(@Query() @Hidden() data?: InfoJWT) {
|
public async logout(@Query() @Hidden() data?: InfoJWT) {
|
||||||
return logout(data!)
|
return logout(data!)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const authorize = async (data: any): Promise<AuthorizeResponse> => {
|
/**
|
||||||
const { username, password, clientId } = data
|
* @summary Update user's password.
|
||||||
|
*/
|
||||||
// Authenticate User
|
@Security('bearerAuth')
|
||||||
const user = await User.findOne({ username })
|
@Patch('updatePassword')
|
||||||
if (!user) throw new Error('Username is not found.')
|
public async updatePassword(
|
||||||
|
@Request() req: express.Request,
|
||||||
const validPass = user.comparePassword(password)
|
@Body() body: UpdatePasswordPayload
|
||||||
if (!validPass) throw new Error('Invalid password.')
|
) {
|
||||||
|
return updatePassword(req, body)
|
||||||
// generate authorization code against clientId
|
|
||||||
const userInfo: InfoJWT = {
|
|
||||||
clientId,
|
|
||||||
userId: user.id
|
|
||||||
}
|
}
|
||||||
const code = AuthController.saveCode(
|
|
||||||
user.id,
|
|
||||||
clientId,
|
|
||||||
generateAuthCode(userInfo)
|
|
||||||
)
|
|
||||||
|
|
||||||
return { code }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = async (data: any): Promise<TokenResponse> => {
|
const token = async (data: any): Promise<TokenResponse> => {
|
||||||
@@ -113,8 +100,26 @@ const token = async (data: any): Promise<TokenResponse> => {
|
|||||||
|
|
||||||
AuthController.deleteCode(userInfo.userId, clientId)
|
AuthController.deleteCode(userInfo.userId, clientId)
|
||||||
|
|
||||||
const accessToken = generateAccessToken(userInfo)
|
// get tokens from DB
|
||||||
const refreshToken = generateRefreshToken(userInfo)
|
const existingTokens = await getTokensFromDB(userInfo.userId, clientId)
|
||||||
|
if (existingTokens) {
|
||||||
|
return {
|
||||||
|
accessToken: existingTokens.accessToken,
|
||||||
|
refreshToken: existingTokens.refreshToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await Client.findOne({ clientId })
|
||||||
|
if (!client) throw new Error('Invalid clientId.')
|
||||||
|
|
||||||
|
const accessToken = generateAccessToken(
|
||||||
|
userInfo,
|
||||||
|
client.accessTokenExpiration
|
||||||
|
)
|
||||||
|
const refreshToken = generateRefreshToken(
|
||||||
|
userInfo,
|
||||||
|
client.refreshTokenExpiration
|
||||||
|
)
|
||||||
|
|
||||||
await saveTokensInDB(userInfo.userId, clientId, accessToken, refreshToken)
|
await saveTokensInDB(userInfo.userId, clientId, accessToken, refreshToken)
|
||||||
|
|
||||||
@@ -122,8 +127,17 @@ const token = async (data: any): Promise<TokenResponse> => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const refresh = async (userInfo: InfoJWT): Promise<TokenResponse> => {
|
const refresh = async (userInfo: InfoJWT): Promise<TokenResponse> => {
|
||||||
const accessToken = generateAccessToken(userInfo)
|
const client = await Client.findOne({ clientId: userInfo.clientId })
|
||||||
const refreshToken = generateRefreshToken(userInfo)
|
if (!client) throw new Error('Invalid clientId.')
|
||||||
|
|
||||||
|
const accessToken = generateAccessToken(
|
||||||
|
userInfo,
|
||||||
|
client.accessTokenExpiration
|
||||||
|
)
|
||||||
|
const refreshToken = generateRefreshToken(
|
||||||
|
userInfo,
|
||||||
|
client.refreshTokenExpiration
|
||||||
|
)
|
||||||
|
|
||||||
await saveTokensInDB(
|
await saveTokensInDB(
|
||||||
userInfo.userId,
|
userInfo.userId,
|
||||||
@@ -139,30 +153,38 @@ const logout = async (userInfo: InfoJWT) => {
|
|||||||
await removeTokensInDB(userInfo.userId, userInfo.clientId)
|
await removeTokensInDB(userInfo.userId, userInfo.clientId)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthorizePayload {
|
const updatePassword = async (
|
||||||
/**
|
req: express.Request,
|
||||||
* Username for user
|
data: UpdatePasswordPayload
|
||||||
* @example "secretuser"
|
) => {
|
||||||
*/
|
const { currentPassword, newPassword } = data
|
||||||
username: string
|
const userId = req.user?.userId
|
||||||
/**
|
const dbUser = await User.findOne({ id: userId })
|
||||||
* Password for user
|
|
||||||
* @example "secretpassword"
|
|
||||||
*/
|
|
||||||
password: string
|
|
||||||
/**
|
|
||||||
* Client ID
|
|
||||||
* @example "clientID1"
|
|
||||||
*/
|
|
||||||
clientId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AuthorizeResponse {
|
if (!dbUser)
|
||||||
/**
|
throw {
|
||||||
* Authorization code
|
code: 404,
|
||||||
* @example "someRandomCryptoString"
|
message: `User not found!`
|
||||||
*/
|
}
|
||||||
code: string
|
|
||||||
|
if (dbUser?.authProvider) {
|
||||||
|
throw {
|
||||||
|
code: 405,
|
||||||
|
message:
|
||||||
|
'Can not update password of user that is created by an external auth provider.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validPass = dbUser.comparePassword(currentPassword)
|
||||||
|
if (!validPass)
|
||||||
|
throw {
|
||||||
|
code: 403,
|
||||||
|
message: `Invalid current password!`
|
||||||
|
}
|
||||||
|
|
||||||
|
dbUser.password = User.hashPassword(newPassword)
|
||||||
|
dbUser.needsToUpdatePassword = false
|
||||||
|
await dbUser.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TokenPayload {
|
interface TokenPayload {
|
||||||
@@ -191,12 +213,25 @@ interface TokenResponse {
|
|||||||
refreshToken: string
|
refreshToken: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface UpdatePasswordPayload {
|
||||||
|
/**
|
||||||
|
* Current Password
|
||||||
|
* @example "currentPasswordString"
|
||||||
|
*/
|
||||||
|
currentPassword: string
|
||||||
|
/**
|
||||||
|
* New Password
|
||||||
|
* @example "newPassword"
|
||||||
|
*/
|
||||||
|
newPassword: string
|
||||||
|
}
|
||||||
|
|
||||||
const verifyAuthCode = async (
|
const verifyAuthCode = async (
|
||||||
clientId: string,
|
clientId: string,
|
||||||
code: string
|
code: string
|
||||||
): Promise<InfoJWT | undefined> => {
|
): Promise<InfoJWT | undefined> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve) => {
|
||||||
jwt.verify(code, process.env.AUTH_CODE_SECRET as string, (err, data) => {
|
jwt.verify(code, process.secrets.AUTH_CODE_SECRET, (err, data) => {
|
||||||
if (err) return resolve(undefined)
|
if (err) return resolve(undefined)
|
||||||
|
|
||||||
const clientInfo: InfoJWT = {
|
const clientInfo: InfoJWT = {
|
||||||
|
|||||||
186
api/src/controllers/authConfig.ts
Normal file
186
api/src/controllers/authConfig.ts
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import express from 'express'
|
||||||
|
import { Security, Route, Tags, Get, Post, Example } from 'tsoa'
|
||||||
|
|
||||||
|
import { LDAPClient, LDAPUser, LDAPGroup, AuthProviderType } from '../utils'
|
||||||
|
import { randomBytes } from 'crypto'
|
||||||
|
import User from '../model/User'
|
||||||
|
import Group from '../model/Group'
|
||||||
|
import Permission from '../model/Permission'
|
||||||
|
|
||||||
|
@Security('bearerAuth')
|
||||||
|
@Route('SASjsApi/authConfig')
|
||||||
|
@Tags('Auth_Config')
|
||||||
|
export class AuthConfigController {
|
||||||
|
/**
|
||||||
|
* @summary Gives the detail of Auth Mechanism.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
@Example({
|
||||||
|
ldap: {
|
||||||
|
LDAP_URL: 'ldaps://my.ldap.server:636',
|
||||||
|
LDAP_BIND_DN: 'cn=admin,ou=system,dc=cloudron',
|
||||||
|
LDAP_BIND_PASSWORD: 'secret',
|
||||||
|
LDAP_USERS_BASE_DN: 'ou=users,dc=cloudron',
|
||||||
|
LDAP_GROUPS_BASE_DN: 'ou=groups,dc=cloudron'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
@Get('/')
|
||||||
|
public getDetail() {
|
||||||
|
return getAuthConfigDetail()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Synchronises LDAP users and groups with internal DB and returns the count of imported users and groups.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
@Example({
|
||||||
|
users: 5,
|
||||||
|
groups: 3
|
||||||
|
})
|
||||||
|
@Post('/synchroniseWithLDAP')
|
||||||
|
public async synchroniseWithLDAP() {
|
||||||
|
return synchroniseWithLDAP()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const synchroniseWithLDAP = async () => {
|
||||||
|
process.logger.info('Syncing LDAP with internal DB')
|
||||||
|
|
||||||
|
const permissions = await Permission.get({})
|
||||||
|
await Permission.deleteMany()
|
||||||
|
await User.deleteMany({ authProvider: AuthProviderType.LDAP })
|
||||||
|
await Group.deleteMany({ authProvider: AuthProviderType.LDAP })
|
||||||
|
|
||||||
|
const ldapClient = await LDAPClient.init()
|
||||||
|
|
||||||
|
process.logger.info('fetching LDAP users')
|
||||||
|
const users = await ldapClient.getAllLDAPUsers()
|
||||||
|
|
||||||
|
process.logger.info('inserting LDAP users to DB')
|
||||||
|
|
||||||
|
const existingUsers: string[] = []
|
||||||
|
const importedUsers: LDAPUser[] = []
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
const usernameExists = await User.findOne({ username: user.username })
|
||||||
|
if (usernameExists) {
|
||||||
|
existingUsers.push(user.username)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashPassword = User.hashPassword(randomBytes(64).toString('hex'))
|
||||||
|
|
||||||
|
await User.create({
|
||||||
|
displayName: user.displayName,
|
||||||
|
username: user.username,
|
||||||
|
password: hashPassword,
|
||||||
|
authProvider: AuthProviderType.LDAP,
|
||||||
|
needsToUpdatePassword: false
|
||||||
|
})
|
||||||
|
|
||||||
|
importedUsers.push(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingUsers.length > 0) {
|
||||||
|
process.logger.info(
|
||||||
|
'Failed to insert following users as they already exist in DB:'
|
||||||
|
)
|
||||||
|
existingUsers.forEach((user) => process.logger.log(`* ${user}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
process.logger.info('fetching LDAP groups')
|
||||||
|
const groups = await ldapClient.getAllLDAPGroups()
|
||||||
|
|
||||||
|
process.logger.info('inserting LDAP groups to DB')
|
||||||
|
|
||||||
|
const existingGroups: string[] = []
|
||||||
|
const importedGroups: LDAPGroup[] = []
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
const groupExists = await Group.findOne({ name: group.name })
|
||||||
|
if (groupExists) {
|
||||||
|
existingGroups.push(group.name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
await Group.create({
|
||||||
|
name: group.name,
|
||||||
|
authProvider: AuthProviderType.LDAP
|
||||||
|
})
|
||||||
|
|
||||||
|
importedGroups.push(group)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingGroups.length > 0) {
|
||||||
|
process.logger.info(
|
||||||
|
'Failed to insert following groups as they already exist in DB:'
|
||||||
|
)
|
||||||
|
existingGroups.forEach((group) => process.logger.log(`* ${group}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
process.logger.info('associating users and groups')
|
||||||
|
|
||||||
|
for (const group of importedGroups) {
|
||||||
|
const dbGroup = await Group.findOne({ name: group.name })
|
||||||
|
if (dbGroup) {
|
||||||
|
for (const member of group.members) {
|
||||||
|
const user = importedUsers.find((user) => user.uid === member)
|
||||||
|
if (user) {
|
||||||
|
const dbUser = await User.findOne({ username: user.username })
|
||||||
|
if (dbUser) await dbGroup.addUser(dbUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.logger.info('setting permissions')
|
||||||
|
|
||||||
|
for (const permission of permissions) {
|
||||||
|
const newPermission = new Permission({
|
||||||
|
path: permission.path,
|
||||||
|
type: permission.type,
|
||||||
|
setting: permission.setting
|
||||||
|
})
|
||||||
|
|
||||||
|
if (permission.user) {
|
||||||
|
const dbUser = await User.findOne({ username: permission.user.username })
|
||||||
|
if (dbUser) newPermission.user = dbUser._id
|
||||||
|
} else if (permission.group) {
|
||||||
|
const dbGroup = await Group.findOne({ name: permission.group.name })
|
||||||
|
if (dbGroup) newPermission.group = dbGroup._id
|
||||||
|
}
|
||||||
|
await newPermission.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
process.logger.info('LDAP synchronization completed!')
|
||||||
|
|
||||||
|
return {
|
||||||
|
userCount: importedUsers.length,
|
||||||
|
groupCount: importedGroups.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAuthConfigDetail = () => {
|
||||||
|
const { AUTH_PROVIDERS } = process.env
|
||||||
|
|
||||||
|
const returnObj: any = {}
|
||||||
|
|
||||||
|
if (AUTH_PROVIDERS === AuthProviderType.LDAP) {
|
||||||
|
const {
|
||||||
|
LDAP_URL,
|
||||||
|
LDAP_BIND_DN,
|
||||||
|
LDAP_BIND_PASSWORD,
|
||||||
|
LDAP_USERS_BASE_DN,
|
||||||
|
LDAP_GROUPS_BASE_DN
|
||||||
|
} = process.env
|
||||||
|
|
||||||
|
returnObj.ldap = {
|
||||||
|
LDAP_URL: LDAP_URL ?? '',
|
||||||
|
LDAP_BIND_DN: LDAP_BIND_DN ?? '',
|
||||||
|
LDAP_BIND_PASSWORD: LDAP_BIND_PASSWORD ?? '',
|
||||||
|
LDAP_USERS_BASE_DN: LDAP_USERS_BASE_DN ?? '',
|
||||||
|
LDAP_GROUPS_BASE_DN: LDAP_GROUPS_BASE_DN ?? ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return returnObj
|
||||||
|
}
|
||||||
@@ -1,18 +1,27 @@
|
|||||||
import { Security, Route, Tags, Example, Post, Body } from 'tsoa'
|
import { Security, Route, Tags, Example, Post, Body, Get } from 'tsoa'
|
||||||
|
|
||||||
import Client, { ClientPayload } from '../model/Client'
|
import Client, {
|
||||||
|
ClientPayload,
|
||||||
|
NUMBER_OF_SECONDS_IN_A_DAY
|
||||||
|
} from '../model/Client'
|
||||||
|
|
||||||
@Security('bearerAuth')
|
@Security('bearerAuth')
|
||||||
@Route('SASjsApi/client')
|
@Route('SASjsApi/client')
|
||||||
@Tags('Client')
|
@Tags('Client')
|
||||||
export class ClientController {
|
export class ClientController {
|
||||||
/**
|
/**
|
||||||
* @summary Create client with the following attributes: ClientId, ClientSecret. Admin only task.
|
* @summary Admin only task. Create client with the following attributes:
|
||||||
|
* ClientId,
|
||||||
|
* ClientSecret,
|
||||||
|
* accessTokenExpiration (optional),
|
||||||
|
* refreshTokenExpiration (optional)
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
@Example<ClientPayload>({
|
@Example<ClientPayload>({
|
||||||
clientId: 'someFormattedClientID1234',
|
clientId: 'someFormattedClientID1234',
|
||||||
clientSecret: 'someRandomCryptoString'
|
clientSecret: 'someRandomCryptoString',
|
||||||
|
accessTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY,
|
||||||
|
refreshTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY * 30
|
||||||
})
|
})
|
||||||
@Post('/')
|
@Post('/')
|
||||||
public async createClient(
|
public async createClient(
|
||||||
@@ -20,10 +29,37 @@ export class ClientController {
|
|||||||
): Promise<ClientPayload> {
|
): Promise<ClientPayload> {
|
||||||
return createClient(body)
|
return createClient(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Admin only task. Returns the list of all the clients
|
||||||
|
*/
|
||||||
|
@Example<ClientPayload[]>([
|
||||||
|
{
|
||||||
|
clientId: 'someClientID1234',
|
||||||
|
clientSecret: 'someRandomCryptoString',
|
||||||
|
accessTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY,
|
||||||
|
refreshTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY * 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
clientId: 'someOtherClientID',
|
||||||
|
clientSecret: 'someOtherRandomCryptoString',
|
||||||
|
accessTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY,
|
||||||
|
refreshTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY * 30
|
||||||
|
}
|
||||||
|
])
|
||||||
|
@Get('/')
|
||||||
|
public async getAllClients(): Promise<ClientPayload[]> {
|
||||||
|
return getAllClients()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const createClient = async (data: any): Promise<ClientPayload> => {
|
const createClient = async (data: ClientPayload): Promise<ClientPayload> => {
|
||||||
const { clientId, clientSecret } = data
|
const {
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
accessTokenExpiration,
|
||||||
|
refreshTokenExpiration
|
||||||
|
} = data
|
||||||
|
|
||||||
// Checking if client is already in the database
|
// Checking if client is already in the database
|
||||||
const clientExist = await Client.findOne({ clientId })
|
const clientExist = await Client.findOne({ clientId })
|
||||||
@@ -32,13 +68,27 @@ const createClient = async (data: any): Promise<ClientPayload> => {
|
|||||||
// Create a new client
|
// Create a new client
|
||||||
const client = new Client({
|
const client = new Client({
|
||||||
clientId,
|
clientId,
|
||||||
clientSecret
|
clientSecret,
|
||||||
|
accessTokenExpiration,
|
||||||
|
refreshTokenExpiration
|
||||||
})
|
})
|
||||||
|
|
||||||
const savedClient = await client.save()
|
const savedClient = await client.save()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
clientId: savedClient.clientId,
|
clientId: savedClient.clientId,
|
||||||
clientSecret: savedClient.clientSecret
|
clientSecret: savedClient.clientSecret,
|
||||||
|
accessTokenExpiration: savedClient.accessTokenExpiration,
|
||||||
|
refreshTokenExpiration: savedClient.refreshTokenExpiration
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getAllClients = async (): Promise<ClientPayload[]> => {
|
||||||
|
return Client.find({}).select({
|
||||||
|
_id: 0,
|
||||||
|
clientId: 1,
|
||||||
|
clientSecret: 1,
|
||||||
|
accessTokenExpiration: 1,
|
||||||
|
refreshTokenExpiration: 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
70
api/src/controllers/code.ts
Normal file
70
api/src/controllers/code.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import express from 'express'
|
||||||
|
import { Request, Security, Route, Tags, Post, Body } from 'tsoa'
|
||||||
|
import { ExecutionController } from './internal'
|
||||||
|
import {
|
||||||
|
getPreProgramVariables,
|
||||||
|
getUserAutoExec,
|
||||||
|
ModeType,
|
||||||
|
parseLogToArray,
|
||||||
|
RunTimeType
|
||||||
|
} from '../utils'
|
||||||
|
|
||||||
|
interface ExecuteCodePayload {
|
||||||
|
/**
|
||||||
|
* Code of program
|
||||||
|
* @example "* Code HERE;"
|
||||||
|
*/
|
||||||
|
code: string
|
||||||
|
/**
|
||||||
|
* runtime for program
|
||||||
|
* @example "js"
|
||||||
|
*/
|
||||||
|
runTime: RunTimeType
|
||||||
|
}
|
||||||
|
|
||||||
|
@Security('bearerAuth')
|
||||||
|
@Route('SASjsApi/code')
|
||||||
|
@Tags('Code')
|
||||||
|
export class CodeController {
|
||||||
|
/**
|
||||||
|
* Execute Code on the Specified Runtime
|
||||||
|
* @summary Run Code and Return Webout Content and Log
|
||||||
|
*/
|
||||||
|
@Post('/execute')
|
||||||
|
public async executeCode(
|
||||||
|
@Request() request: express.Request,
|
||||||
|
@Body() body: ExecuteCodePayload
|
||||||
|
): Promise<string | Buffer> {
|
||||||
|
return executeCode(request, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const executeCode = async (
|
||||||
|
req: express.Request,
|
||||||
|
{ code, runTime }: ExecuteCodePayload
|
||||||
|
) => {
|
||||||
|
const { user } = req
|
||||||
|
const userAutoExec =
|
||||||
|
process.env.MODE === ModeType.Server
|
||||||
|
? user?.autoExec
|
||||||
|
: await getUserAutoExec()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { result } = await new ExecutionController().executeProgram({
|
||||||
|
program: code,
|
||||||
|
preProgramVariables: getPreProgramVariables(req),
|
||||||
|
vars: { ...req.query, _debug: 131 },
|
||||||
|
otherArgs: { userAutoExec },
|
||||||
|
runTime: runTime
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
} catch (err: any) {
|
||||||
|
throw {
|
||||||
|
code: 400,
|
||||||
|
status: 'failure',
|
||||||
|
message: 'Job execution failed.',
|
||||||
|
error: typeof err === 'object' ? err.toString() : err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import path from 'path'
|
||||||
|
import express, { Express } from 'express'
|
||||||
import {
|
import {
|
||||||
Security,
|
Security,
|
||||||
|
Request,
|
||||||
Route,
|
Route,
|
||||||
Tags,
|
Tags,
|
||||||
Example,
|
Example,
|
||||||
@@ -8,35 +11,40 @@ import {
|
|||||||
Response,
|
Response,
|
||||||
Query,
|
Query,
|
||||||
Get,
|
Get,
|
||||||
Patch
|
Patch,
|
||||||
|
UploadedFile,
|
||||||
|
FormField,
|
||||||
|
Delete,
|
||||||
|
Hidden
|
||||||
} from 'tsoa'
|
} from 'tsoa'
|
||||||
import { fileExists, readFile, createFile } from '@sasjs/utils'
|
import {
|
||||||
|
fileExists,
|
||||||
|
moveFile,
|
||||||
|
createFolder,
|
||||||
|
deleteFile as deleteFileOnSystem,
|
||||||
|
deleteFolder as deleteFolderOnSystem,
|
||||||
|
folderExists,
|
||||||
|
listFilesInFolder,
|
||||||
|
listSubFoldersInFolder,
|
||||||
|
isFolder,
|
||||||
|
FileTree,
|
||||||
|
isFileTree
|
||||||
|
} from '@sasjs/utils'
|
||||||
import { createFileTree, ExecutionController, getTreeExample } from './internal'
|
import { createFileTree, ExecutionController, getTreeExample } from './internal'
|
||||||
|
|
||||||
import { FileTree, isFileTree, TreeNode } from '../types'
|
import { TreeNode } from '../types'
|
||||||
import path from 'path'
|
import { getFilesFolder } from '../utils'
|
||||||
import { getTmpFilesFolderPath } from '../utils'
|
|
||||||
|
|
||||||
interface DeployPayload {
|
interface DeployPayload {
|
||||||
appLoc?: string
|
appLoc: string
|
||||||
|
streamWebFolder?: string
|
||||||
fileTree: FileTree
|
fileTree: FileTree
|
||||||
}
|
}
|
||||||
interface FilePayload {
|
|
||||||
/**
|
|
||||||
* Path of the file
|
|
||||||
* @example "/Public/somefolder/some.file"
|
|
||||||
*/
|
|
||||||
filePath: string
|
|
||||||
/**
|
|
||||||
* Contents of the file
|
|
||||||
* @example "Contents of the File"
|
|
||||||
*/
|
|
||||||
fileContent: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DeployResponse {
|
interface DeployResponse {
|
||||||
status: string
|
status: string
|
||||||
message: string
|
message: string
|
||||||
|
streamServiceName?: string
|
||||||
example?: FileTree
|
example?: FileTree
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,11 +59,32 @@ interface GetFileTreeResponse {
|
|||||||
tree: TreeNode
|
tree: TreeNode
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpdateFileResponse {
|
interface FileFolderResponse {
|
||||||
status: string
|
status: string
|
||||||
message?: string
|
message?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AddFolderPayload {
|
||||||
|
/**
|
||||||
|
* Location of folder
|
||||||
|
* @example "/Public/someFolder"
|
||||||
|
*/
|
||||||
|
folderPath: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RenamePayload {
|
||||||
|
/**
|
||||||
|
* Old path of file/folder
|
||||||
|
* @example "/Public/someFolder"
|
||||||
|
*/
|
||||||
|
oldPath: string
|
||||||
|
/**
|
||||||
|
* New path of file/folder
|
||||||
|
* @example "/Public/newFolder"
|
||||||
|
*/
|
||||||
|
newPath: string
|
||||||
|
}
|
||||||
|
|
||||||
const fileTreeExample = getTreeExample()
|
const fileTreeExample = getTreeExample()
|
||||||
|
|
||||||
const successDeployResponse: DeployResponse = {
|
const successDeployResponse: DeployResponse = {
|
||||||
@@ -89,57 +118,158 @@ export class DriveController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary Get file from SASjs Drive
|
* Accepts JSON file and zipped compressed JSON file as well.
|
||||||
* @query filePath Location of SAS program
|
* Compressed file should only contain one JSON file and should have same name
|
||||||
* @example filePath "/Public/somefolder/some.file"
|
* as of compressed file e.g. deploy.JSON should be compressed to deploy.JSON.zip
|
||||||
|
* Any other file or JSON file in zipped will be ignored!
|
||||||
|
*
|
||||||
|
* @summary Creates/updates files within SASjs Drive using uploaded JSON/compressed JSON file.
|
||||||
|
*
|
||||||
*/
|
*/
|
||||||
@Example<GetFileResponse>({
|
@Example<DeployResponse>(successDeployResponse)
|
||||||
status: 'success',
|
@Response<DeployResponse>(400, 'Invalid Format', invalidDeployFormatResponse)
|
||||||
fileContent: 'Contents of the File'
|
@Response<DeployResponse>(500, 'Execution Error', execDeployErrorResponse)
|
||||||
})
|
@Post('/deploy/upload')
|
||||||
@Response<GetFileResponse>(400, 'Unable to get File', {
|
public async deployUpload(
|
||||||
status: 'failure',
|
@UploadedFile() file: Express.Multer.File, // passing here for API docs
|
||||||
message: 'File request failed.'
|
@Query() @Hidden() body?: DeployPayload // Hidden decorator has be optional
|
||||||
})
|
): Promise<DeployResponse> {
|
||||||
@Get('/file')
|
return deploy(body!)
|
||||||
public async getFile(@Query() filePath: string): Promise<GetFileResponse> {
|
|
||||||
return getFile(filePath)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
*
|
||||||
|
* @summary Get file from SASjs Drive
|
||||||
|
* @query _filePath Location of SAS program
|
||||||
|
* @example _filePath "/Public/somefolder/some.file"
|
||||||
|
*/
|
||||||
|
@Get('/file')
|
||||||
|
public async getFile(
|
||||||
|
@Request() request: express.Request,
|
||||||
|
@Query() _filePath: string
|
||||||
|
) {
|
||||||
|
return getFile(request, _filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @summary Get folder contents from SASjs Drive
|
||||||
|
* @query _folderPath Location of SAS program
|
||||||
|
* @example _folderPath "/Public/somefolder"
|
||||||
|
*/
|
||||||
|
@Get('/folder')
|
||||||
|
public async getFolder(@Query() _folderPath?: string) {
|
||||||
|
return getFolder(_folderPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @summary Delete file from SASjs Drive
|
||||||
|
* @query _filePath Location of file
|
||||||
|
* @example _filePath "/Public/somefolder/some.file"
|
||||||
|
*/
|
||||||
|
@Delete('/file')
|
||||||
|
public async deleteFile(@Query() _filePath: string) {
|
||||||
|
return deleteFile(_filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @summary Delete folder from SASjs Drive
|
||||||
|
* @query _folderPath Location of folder
|
||||||
|
* @example _folderPath "/Public/somefolder/"
|
||||||
|
*/
|
||||||
|
@Delete('/folder')
|
||||||
|
public async deleteFolder(@Query() _folderPath: string) {
|
||||||
|
return deleteFolder(_folderPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* It's optional to either provide `_filePath` in url as query parameter
|
||||||
|
* Or provide `filePath` in body as form field.
|
||||||
|
* But it's required to provide else API will respond with Bad Request.
|
||||||
|
*
|
||||||
* @summary Create a file in SASjs Drive
|
* @summary Create a file in SASjs Drive
|
||||||
|
* @param _filePath Location of file
|
||||||
|
* @example _filePath "/Public/somefolder/some.file.sas"
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
@Example<UpdateFileResponse>({
|
@Example<FileFolderResponse>({
|
||||||
status: 'success'
|
status: 'success'
|
||||||
})
|
})
|
||||||
@Response<UpdateFileResponse>(400, 'File already exists', {
|
@Response<FileFolderResponse>(403, 'File already exists', {
|
||||||
status: 'failure',
|
status: 'failure',
|
||||||
message: 'File request failed.'
|
message: 'File request failed.'
|
||||||
})
|
})
|
||||||
@Post('/file')
|
@Post('/file')
|
||||||
public async saveFile(
|
public async saveFile(
|
||||||
@Body() body: FilePayload
|
@UploadedFile() file: Express.Multer.File,
|
||||||
): Promise<UpdateFileResponse> {
|
@Query() _filePath?: string,
|
||||||
return saveFile(body)
|
@FormField() filePath?: string
|
||||||
|
): Promise<FileFolderResponse> {
|
||||||
|
return saveFile((_filePath ?? filePath)!, file)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary Modify a file in SASjs Drive
|
* @summary Create an empty folder in SASjs Drive
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
@Example<UpdateFileResponse>({
|
@Example<FileFolderResponse>({
|
||||||
status: 'success'
|
status: 'success'
|
||||||
})
|
})
|
||||||
@Response<UpdateFileResponse>(400, 'Unable to get File', {
|
@Response<FileFolderResponse>(409, 'Folder already exists', {
|
||||||
|
status: 'failure',
|
||||||
|
message: 'Add folder request failed.'
|
||||||
|
})
|
||||||
|
@Post('/folder')
|
||||||
|
public async addFolder(
|
||||||
|
@Body() body: AddFolderPayload
|
||||||
|
): Promise<FileFolderResponse> {
|
||||||
|
return addFolder(body.folderPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* It's optional to either provide `_filePath` in url as query parameter
|
||||||
|
* Or provide `filePath` in body as form field.
|
||||||
|
* But it's required to provide else API will respond with Bad Request.
|
||||||
|
*
|
||||||
|
* @summary Modify a file in SASjs Drive
|
||||||
|
* @param _filePath Location of SAS program
|
||||||
|
* @example _filePath "/Public/somefolder/some.file.sas"
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
@Example<FileFolderResponse>({
|
||||||
|
status: 'success'
|
||||||
|
})
|
||||||
|
@Response<FileFolderResponse>(403, `File doesn't exist`, {
|
||||||
status: 'failure',
|
status: 'failure',
|
||||||
message: 'File request failed.'
|
message: 'File request failed.'
|
||||||
})
|
})
|
||||||
@Patch('/file')
|
@Patch('/file')
|
||||||
public async updateFile(
|
public async updateFile(
|
||||||
@Body() body: FilePayload
|
@UploadedFile() file: Express.Multer.File,
|
||||||
): Promise<UpdateFileResponse> {
|
@Query() _filePath?: string,
|
||||||
return updateFile(body)
|
@FormField() filePath?: string
|
||||||
|
): Promise<FileFolderResponse> {
|
||||||
|
return updateFile((_filePath ?? filePath)!, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Renames a file/folder in SASjs Drive
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
@Example<FileFolderResponse>({
|
||||||
|
status: 'success'
|
||||||
|
})
|
||||||
|
@Response<FileFolderResponse>(409, 'Folder already exists', {
|
||||||
|
status: 'failure',
|
||||||
|
message: 'rename request failed.'
|
||||||
|
})
|
||||||
|
@Post('/rename')
|
||||||
|
public async rename(
|
||||||
|
@Body() body: RenamePayload
|
||||||
|
): Promise<FileFolderResponse> {
|
||||||
|
return rename(body.oldPath, body.newPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -153,91 +283,292 @@ export class DriveController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getFileTree = () => {
|
const getFileTree = () => {
|
||||||
const tree = new ExecutionController().buildDirectorytree()
|
const tree = new ExecutionController().buildDirectoryTree()
|
||||||
return { status: 'success', tree }
|
return { status: 'success', tree }
|
||||||
}
|
}
|
||||||
|
|
||||||
const deploy = async (data: DeployPayload) => {
|
const deploy = async (data: DeployPayload) => {
|
||||||
|
const driveFilesPath = getFilesFolder()
|
||||||
|
|
||||||
|
const appLocParts = data.appLoc.replace(/^\//, '').split('/')
|
||||||
|
|
||||||
|
const appLocPath = path
|
||||||
|
.join(getFilesFolder(), ...appLocParts)
|
||||||
|
.replace(new RegExp('/', 'g'), path.sep)
|
||||||
|
|
||||||
|
if (!appLocPath.includes(driveFilesPath)) {
|
||||||
|
throw new Error('appLoc cannot be outside drive.')
|
||||||
|
}
|
||||||
|
|
||||||
if (!isFileTree(data.fileTree)) {
|
if (!isFileTree(data.fileTree)) {
|
||||||
throw { code: 400, ...invalidDeployFormatResponse }
|
throw { code: 400, ...invalidDeployFormatResponse }
|
||||||
}
|
}
|
||||||
|
|
||||||
await createFileTree(
|
await createFileTree(data.fileTree.members, appLocParts).catch((err) => {
|
||||||
data.fileTree.members,
|
|
||||||
data.appLoc ? data.appLoc.replace(/^\//, '').split('/') : []
|
|
||||||
).catch((err) => {
|
|
||||||
throw { code: 500, ...execDeployErrorResponse, ...err }
|
throw { code: 500, ...execDeployErrorResponse, ...err }
|
||||||
})
|
})
|
||||||
|
|
||||||
return successDeployResponse
|
return successDeployResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
const getFile = async (filePath: string): Promise<GetFileResponse> => {
|
const getFile = async (req: express.Request, filePath: string) => {
|
||||||
try {
|
const driveFilesPath = getFilesFolder()
|
||||||
const filePathFull = path
|
|
||||||
.join(getTmpFilesFolderPath(), filePath)
|
|
||||||
.replace(new RegExp('/', 'g'), path.sep)
|
|
||||||
|
|
||||||
await validateFilePath(filePathFull)
|
const filePathFull = path
|
||||||
const fileContent = await readFile(filePathFull)
|
.join(getFilesFolder(), filePath)
|
||||||
|
.replace(new RegExp('/', 'g'), path.sep)
|
||||||
|
|
||||||
return { status: 'success', fileContent: fileContent }
|
if (!filePathFull.includes(driveFilesPath))
|
||||||
} catch (err: any) {
|
|
||||||
throw {
|
throw {
|
||||||
code: 400,
|
code: 400,
|
||||||
status: 'failure',
|
status: 'Bad Request',
|
||||||
message: 'File request failed.',
|
message: `Can't get file outside drive.`
|
||||||
error: typeof err === 'object' ? err.toString() : err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!(await fileExists(filePathFull)))
|
||||||
|
throw {
|
||||||
|
code: 404,
|
||||||
|
status: 'Not Found',
|
||||||
|
message: `File doesn't exist.`
|
||||||
|
}
|
||||||
|
|
||||||
|
const extension = path.extname(filePathFull).toLowerCase()
|
||||||
|
if (extension === '.sas') {
|
||||||
|
req.res?.setHeader('Content-type', 'text/plain')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
req.res?.sendFile(path.resolve(filePathFull), { dotfiles: 'allow' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveFile = async (body: FilePayload): Promise<GetFileResponse> => {
|
const getFolder = async (folderPath?: string) => {
|
||||||
const { filePath, fileContent } = body
|
const driveFilesPath = getFilesFolder()
|
||||||
try {
|
|
||||||
const filePathFull = path
|
if (folderPath) {
|
||||||
.join(getTmpFilesFolderPath(), filePath)
|
const folderPathFull = path
|
||||||
|
.join(getFilesFolder(), folderPath)
|
||||||
.replace(new RegExp('/', 'g'), path.sep)
|
.replace(new RegExp('/', 'g'), path.sep)
|
||||||
|
|
||||||
if (await fileExists(filePathFull)) {
|
if (!folderPathFull.includes(driveFilesPath))
|
||||||
throw 'DriveController: File already exists.'
|
throw {
|
||||||
|
code: 400,
|
||||||
|
status: 'Bad Request',
|
||||||
|
message: `Can't get folder outside drive.`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await folderExists(folderPathFull)))
|
||||||
|
throw {
|
||||||
|
code: 404,
|
||||||
|
status: 'Not Found',
|
||||||
|
message: `Folder doesn't exist.`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await isFolder(folderPathFull)))
|
||||||
|
throw {
|
||||||
|
code: 400,
|
||||||
|
status: 'Bad Request',
|
||||||
|
message: 'Not a Folder.'
|
||||||
|
}
|
||||||
|
|
||||||
|
const files: string[] = await listFilesInFolder(folderPathFull)
|
||||||
|
const folders: string[] = await listSubFoldersInFolder(folderPathFull)
|
||||||
|
return { files, folders }
|
||||||
|
}
|
||||||
|
|
||||||
|
const files: string[] = await listFilesInFolder(driveFilesPath)
|
||||||
|
const folders: string[] = await listSubFoldersInFolder(driveFilesPath)
|
||||||
|
return { files, folders }
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteFile = async (filePath: string) => {
|
||||||
|
const driveFilesPath = getFilesFolder()
|
||||||
|
|
||||||
|
const filePathFull = path
|
||||||
|
.join(getFilesFolder(), filePath)
|
||||||
|
.replace(new RegExp('/', 'g'), path.sep)
|
||||||
|
|
||||||
|
if (!filePathFull.includes(driveFilesPath))
|
||||||
|
throw {
|
||||||
|
code: 400,
|
||||||
|
status: 'Bad Request',
|
||||||
|
message: `Can't delete file outside drive.`
|
||||||
}
|
}
|
||||||
await createFile(filePathFull, fileContent)
|
|
||||||
|
if (!(await fileExists(filePathFull)))
|
||||||
|
throw {
|
||||||
|
code: 404,
|
||||||
|
status: 'Not Found',
|
||||||
|
message: `File doesn't exist.`
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteFileOnSystem(filePathFull)
|
||||||
|
|
||||||
|
return { status: 'success' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteFolder = async (folderPath: string) => {
|
||||||
|
const driveFolderPath = getFilesFolder()
|
||||||
|
|
||||||
|
const folderPathFull = path
|
||||||
|
.join(getFilesFolder(), folderPath)
|
||||||
|
.replace(new RegExp('/', 'g'), path.sep)
|
||||||
|
|
||||||
|
if (!folderPathFull.includes(driveFolderPath))
|
||||||
|
throw {
|
||||||
|
code: 400,
|
||||||
|
status: 'Bad Request',
|
||||||
|
message: `Can't delete folder outside drive.`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await folderExists(folderPathFull)))
|
||||||
|
throw {
|
||||||
|
code: 404,
|
||||||
|
status: 'Not Found',
|
||||||
|
message: `Folder doesn't exist.`
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteFolderOnSystem(folderPathFull)
|
||||||
|
|
||||||
|
return { status: 'success' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveFile = async (
|
||||||
|
filePath: string,
|
||||||
|
multerFile: Express.Multer.File
|
||||||
|
): Promise<GetFileResponse> => {
|
||||||
|
const driveFilesPath = getFilesFolder()
|
||||||
|
|
||||||
|
const filePathFull = path
|
||||||
|
.join(driveFilesPath, filePath)
|
||||||
|
.replace(new RegExp('/', 'g'), path.sep)
|
||||||
|
|
||||||
|
if (!filePathFull.includes(driveFilesPath))
|
||||||
|
throw {
|
||||||
|
code: 400,
|
||||||
|
status: 'Bad Request',
|
||||||
|
message: `Can't put file outside drive.`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await fileExists(filePathFull))
|
||||||
|
throw {
|
||||||
|
code: 409,
|
||||||
|
status: 'Conflict',
|
||||||
|
message: 'File already exists.'
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderPath = path.dirname(filePathFull)
|
||||||
|
await createFolder(folderPath)
|
||||||
|
await moveFile(multerFile.path, filePathFull)
|
||||||
|
|
||||||
|
return { status: 'success' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const addFolder = async (folderPath: string): Promise<FileFolderResponse> => {
|
||||||
|
const drivePath = getFilesFolder()
|
||||||
|
|
||||||
|
const folderPathFull = path
|
||||||
|
.join(drivePath, folderPath)
|
||||||
|
.replace(new RegExp('/', 'g'), path.sep)
|
||||||
|
|
||||||
|
if (!folderPathFull.includes(drivePath))
|
||||||
|
throw {
|
||||||
|
code: 400,
|
||||||
|
status: 'Bad Request',
|
||||||
|
message: `Can't put folder outside drive.`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await folderExists(folderPathFull))
|
||||||
|
throw {
|
||||||
|
code: 409,
|
||||||
|
status: 'Conflict',
|
||||||
|
message: 'Folder already exists.'
|
||||||
|
}
|
||||||
|
|
||||||
|
await createFolder(folderPathFull)
|
||||||
|
|
||||||
|
return { status: 'success' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const rename = async (
|
||||||
|
oldPath: string,
|
||||||
|
newPath: string
|
||||||
|
): Promise<FileFolderResponse> => {
|
||||||
|
const drivePath = getFilesFolder()
|
||||||
|
|
||||||
|
const oldPathFull = path
|
||||||
|
.join(drivePath, oldPath)
|
||||||
|
.replace(new RegExp('/', 'g'), path.sep)
|
||||||
|
|
||||||
|
const newPathFull = path
|
||||||
|
.join(drivePath, newPath)
|
||||||
|
.replace(new RegExp('/', 'g'), path.sep)
|
||||||
|
|
||||||
|
if (!oldPathFull.includes(drivePath))
|
||||||
|
throw {
|
||||||
|
code: 400,
|
||||||
|
status: 'Bad Request',
|
||||||
|
message: `Old path can't be outside of drive.`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!newPathFull.includes(drivePath))
|
||||||
|
throw {
|
||||||
|
code: 400,
|
||||||
|
status: 'Bad Request',
|
||||||
|
message: `New path can't be outside of drive.`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await isFolder(oldPathFull)) {
|
||||||
|
if (await folderExists(newPathFull))
|
||||||
|
throw {
|
||||||
|
code: 409,
|
||||||
|
status: 'Conflict',
|
||||||
|
message: 'Folder with new name already exists.'
|
||||||
|
}
|
||||||
|
else moveFile(oldPathFull, newPathFull)
|
||||||
|
|
||||||
return { status: 'success' }
|
return { status: 'success' }
|
||||||
} catch (err: any) {
|
} else if (await fileExists(oldPathFull)) {
|
||||||
throw {
|
if (await fileExists(newPathFull))
|
||||||
code: 400,
|
throw {
|
||||||
status: 'failure',
|
code: 409,
|
||||||
message: 'File request failed.',
|
status: 'Conflict',
|
||||||
error: typeof err === 'object' ? err.toString() : err
|
message: 'File with new name already exists.'
|
||||||
}
|
}
|
||||||
}
|
else moveFile(oldPathFull, newPathFull)
|
||||||
}
|
|
||||||
|
|
||||||
const updateFile = async (body: FilePayload): Promise<GetFileResponse> => {
|
|
||||||
const { filePath, fileContent } = body
|
|
||||||
try {
|
|
||||||
const filePathFull = path
|
|
||||||
.join(getTmpFilesFolderPath(), filePath)
|
|
||||||
.replace(new RegExp('/', 'g'), path.sep)
|
|
||||||
|
|
||||||
await validateFilePath(filePathFull)
|
|
||||||
await createFile(filePathFull, fileContent)
|
|
||||||
|
|
||||||
return { status: 'success' }
|
return { status: 'success' }
|
||||||
} catch (err: any) {
|
}
|
||||||
throw {
|
|
||||||
code: 400,
|
throw {
|
||||||
status: 'failure',
|
code: 404,
|
||||||
message: 'File request failed.',
|
status: 'Not Found',
|
||||||
error: typeof err === 'object' ? err.toString() : err
|
message: 'No file/folder found for provided path.'
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const validateFilePath = async (filePath: string) => {
|
const updateFile = async (
|
||||||
if (!(await fileExists(filePath))) {
|
filePath: string,
|
||||||
throw 'DriveController: File does not exists.'
|
multerFile: Express.Multer.File
|
||||||
}
|
): Promise<GetFileResponse> => {
|
||||||
|
const driveFilesPath = getFilesFolder()
|
||||||
|
|
||||||
|
const filePathFull = path
|
||||||
|
.join(driveFilesPath, filePath)
|
||||||
|
.replace(new RegExp('/', 'g'), path.sep)
|
||||||
|
|
||||||
|
if (!filePathFull.includes(driveFilesPath))
|
||||||
|
throw {
|
||||||
|
code: 400,
|
||||||
|
status: 'Bad Request',
|
||||||
|
message: `Can't modify file outside drive.`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await fileExists(filePathFull)))
|
||||||
|
throw {
|
||||||
|
code: 404,
|
||||||
|
status: 'Not Found',
|
||||||
|
message: `File doesn't exist.`
|
||||||
|
}
|
||||||
|
|
||||||
|
await moveFile(multerFile.path, filePathFull)
|
||||||
|
|
||||||
|
return { status: 'success' }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,17 +10,18 @@ import {
|
|||||||
Body
|
Body
|
||||||
} from 'tsoa'
|
} from 'tsoa'
|
||||||
|
|
||||||
import Group, { GroupPayload } from '../model/Group'
|
import Group, { GroupPayload, PUBLIC_GROUP_NAME } from '../model/Group'
|
||||||
import User from '../model/User'
|
import User from '../model/User'
|
||||||
|
import { AuthProviderType } from '../utils'
|
||||||
import { UserResponse } from './user'
|
import { UserResponse } from './user'
|
||||||
|
|
||||||
interface GroupResponse {
|
export interface GroupResponse {
|
||||||
groupId: number
|
groupId: number
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GroupDetailsResponse {
|
export interface GroupDetailsResponse {
|
||||||
groupId: number
|
groupId: number
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
@@ -28,6 +29,11 @@ interface GroupDetailsResponse {
|
|||||||
users: UserResponse[]
|
users: UserResponse[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GetGroupBy {
|
||||||
|
groupId?: number
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
|
||||||
@Security('bearerAuth')
|
@Security('bearerAuth')
|
||||||
@Route('SASjsApi/group')
|
@Route('SASjsApi/group')
|
||||||
@Tags('Group')
|
@Tags('Group')
|
||||||
@@ -66,6 +72,18 @@ export class GroupController {
|
|||||||
return createGroup(body)
|
return createGroup(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Get list of members of a group (userName). All users can request this.
|
||||||
|
* @param name The group's name
|
||||||
|
* @example dcgroup
|
||||||
|
*/
|
||||||
|
@Get('by/groupname/{name}')
|
||||||
|
public async getGroupByGroupName(
|
||||||
|
@Path() name: string
|
||||||
|
): Promise<GroupDetailsResponse> {
|
||||||
|
return getGroup({ name })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary Get list of members of a group (userName). All users can request this.
|
* @summary Get list of members of a group (userName). All users can request this.
|
||||||
* @param groupId The group's identifier
|
* @param groupId The group's identifier
|
||||||
@@ -75,7 +93,7 @@ export class GroupController {
|
|||||||
public async getGroup(
|
public async getGroup(
|
||||||
@Path() groupId: number
|
@Path() groupId: number
|
||||||
): Promise<GroupDetailsResponse> {
|
): Promise<GroupDetailsResponse> {
|
||||||
return getGroup(groupId)
|
return getGroup({ groupId })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -129,9 +147,15 @@ export class GroupController {
|
|||||||
*/
|
*/
|
||||||
@Delete('{groupId}')
|
@Delete('{groupId}')
|
||||||
public async deleteGroup(@Path() groupId: number) {
|
public async deleteGroup(@Path() groupId: number) {
|
||||||
const { deletedCount } = await Group.deleteOne({ groupId })
|
const group = await Group.findOne({ groupId })
|
||||||
if (deletedCount) return
|
if (!group)
|
||||||
throw new Error('No Group deleted!')
|
throw {
|
||||||
|
code: 404,
|
||||||
|
status: 'Not Found',
|
||||||
|
message: 'Group not found.'
|
||||||
|
}
|
||||||
|
|
||||||
|
return await group.remove()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +169,15 @@ const createGroup = async ({
|
|||||||
description,
|
description,
|
||||||
isActive
|
isActive
|
||||||
}: GroupPayload): Promise<GroupDetailsResponse> => {
|
}: GroupPayload): Promise<GroupDetailsResponse> => {
|
||||||
|
// Checking if user is already in the database
|
||||||
|
const groupnameExist = await Group.findOne({ name })
|
||||||
|
if (groupnameExist)
|
||||||
|
throw {
|
||||||
|
code: 409,
|
||||||
|
status: 'Conflict',
|
||||||
|
message: 'Group name already exists.'
|
||||||
|
}
|
||||||
|
|
||||||
const group = new Group({
|
const group = new Group({
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
@@ -162,15 +195,20 @@ const createGroup = async ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getGroup = async (groupId: number): Promise<GroupDetailsResponse> => {
|
const getGroup = async (findBy: GetGroupBy): Promise<GroupDetailsResponse> => {
|
||||||
const group = (await Group.findOne(
|
const group = (await Group.findOne(
|
||||||
{ groupId },
|
findBy,
|
||||||
'groupId name description isActive users -_id'
|
'groupId name description isActive users -_id'
|
||||||
).populate(
|
).populate(
|
||||||
'users',
|
'users',
|
||||||
'id username displayName -_id'
|
'id username displayName isAdmin -_id'
|
||||||
)) as unknown as GroupDetailsResponse
|
)) as unknown as GroupDetailsResponse
|
||||||
if (!group) throw new Error('Group not found.')
|
if (!group)
|
||||||
|
throw {
|
||||||
|
code: 404,
|
||||||
|
status: 'Not Found',
|
||||||
|
message: 'Group not found.'
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
groupId: group.groupId,
|
groupId: group.groupId,
|
||||||
@@ -199,16 +237,53 @@ const updateUsersListInGroup = async (
|
|||||||
action: 'addUser' | 'removeUser'
|
action: 'addUser' | 'removeUser'
|
||||||
): Promise<GroupDetailsResponse> => {
|
): Promise<GroupDetailsResponse> => {
|
||||||
const group = await Group.findOne({ groupId })
|
const group = await Group.findOne({ groupId })
|
||||||
if (!group) throw new Error('Group not found.')
|
if (!group)
|
||||||
|
throw {
|
||||||
|
code: 404,
|
||||||
|
status: 'Not Found',
|
||||||
|
message: 'Group not found.'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group.name === PUBLIC_GROUP_NAME)
|
||||||
|
throw {
|
||||||
|
code: 400,
|
||||||
|
status: 'Bad Request',
|
||||||
|
message: `Can't add/remove user to '${PUBLIC_GROUP_NAME}' group.`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group.authProvider)
|
||||||
|
throw {
|
||||||
|
code: 405,
|
||||||
|
status: 'Method Not Allowed',
|
||||||
|
message: `Can't add/remove user to group created by external auth provider.`
|
||||||
|
}
|
||||||
|
|
||||||
const user = await User.findOne({ id: userId })
|
const user = await User.findOne({ id: userId })
|
||||||
if (!user) throw new Error('User not found.')
|
if (!user)
|
||||||
|
throw {
|
||||||
|
code: 404,
|
||||||
|
status: 'Not Found',
|
||||||
|
message: 'User not found.'
|
||||||
|
}
|
||||||
|
|
||||||
const updatedGroup = (action === 'addUser'
|
if (user.authProvider)
|
||||||
? await group.addUser(user._id)
|
throw {
|
||||||
: await group.removeUser(user._id)) as unknown as GroupDetailsResponse
|
code: 405,
|
||||||
|
status: 'Method Not Allowed',
|
||||||
|
message: `Can't add/remove user to group created by external auth provider.`
|
||||||
|
}
|
||||||
|
|
||||||
if (!updatedGroup) throw new Error('Unable to update group')
|
const updatedGroup =
|
||||||
|
action === 'addUser'
|
||||||
|
? await group.addUser(user)
|
||||||
|
: await group.removeUser(user)
|
||||||
|
|
||||||
|
if (!updatedGroup)
|
||||||
|
throw {
|
||||||
|
code: 400,
|
||||||
|
status: 'Bad Request',
|
||||||
|
message: 'Unable to update group.'
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
groupId: updatedGroup.groupId,
|
groupId: updatedGroup.groupId,
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
export * from './auth'
|
export * from './auth'
|
||||||
|
export * from './authConfig'
|
||||||
export * from './client'
|
export * from './client'
|
||||||
|
export * from './code'
|
||||||
export * from './drive'
|
export * from './drive'
|
||||||
export * from './group'
|
export * from './group'
|
||||||
|
export * from './info'
|
||||||
|
export * from './permission'
|
||||||
|
export * from './session'
|
||||||
export * from './stp'
|
export * from './stp'
|
||||||
export * from './user'
|
export * from './user'
|
||||||
|
export * from './web'
|
||||||
|
|||||||
58
api/src/controllers/info.ts
Normal file
58
api/src/controllers/info.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { Route, Tags, Example, Get } from 'tsoa'
|
||||||
|
import { getAuthorizedRoutes } from '../utils'
|
||||||
|
export interface AuthorizedRoutesResponse {
|
||||||
|
paths: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InfoResponse {
|
||||||
|
mode: string
|
||||||
|
cors: string
|
||||||
|
whiteList: string[]
|
||||||
|
protocol: string
|
||||||
|
runTimes: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
@Route('SASjsApi/info')
|
||||||
|
@Tags('Info')
|
||||||
|
export class InfoController {
|
||||||
|
/**
|
||||||
|
* @summary Get server info (mode, cors, whiteList, protocol).
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
@Example<InfoResponse>({
|
||||||
|
mode: 'desktop',
|
||||||
|
cors: 'enable',
|
||||||
|
whiteList: ['http://example.com', 'http://example2.com'],
|
||||||
|
protocol: 'http',
|
||||||
|
runTimes: ['sas', 'js']
|
||||||
|
})
|
||||||
|
@Get('/')
|
||||||
|
public info(): InfoResponse {
|
||||||
|
const response = {
|
||||||
|
mode: process.env.MODE ?? 'desktop',
|
||||||
|
cors:
|
||||||
|
process.env.CORS ||
|
||||||
|
(process.env.MODE === 'server' ? 'disable' : 'enable'),
|
||||||
|
whiteList:
|
||||||
|
process.env.WHITELIST?.split(' ')?.filter((url) => !!url) ?? [],
|
||||||
|
protocol: process.env.PROTOCOL ?? 'http',
|
||||||
|
runTimes: process.runTimes
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Get the list of available routes to which permissions can be applied. Used to populate the dialog in the URI Permissions feature.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
@Example<AuthorizedRoutesResponse>({
|
||||||
|
paths: ['/AppStream', '/SASjsApi/stp/execute']
|
||||||
|
})
|
||||||
|
@Get('/authorizedRoutes')
|
||||||
|
public authorizedRoutes(): AuthorizedRoutesResponse {
|
||||||
|
const response = {
|
||||||
|
paths: getAuthorizedRoutes()
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,132 +1,142 @@
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import { getSessionController } from './'
|
import { getSessionController, processProgram } from './'
|
||||||
import { readFile, fileExists, createFile, moveFile } from '@sasjs/utils'
|
import { readFile, fileExists, createFile, readFileBinary } from '@sasjs/utils'
|
||||||
import { PreProgramVars, TreeNode } from '../../types'
|
import { PreProgramVars, Session, TreeNode } from '../../types'
|
||||||
import { generateFileUploadSasCode, getTmpFilesFolderPath } from '../../utils'
|
import {
|
||||||
|
extractHeaders,
|
||||||
|
getFilesFolder,
|
||||||
|
HTTPHeaders,
|
||||||
|
isDebugOn,
|
||||||
|
RunTimeType
|
||||||
|
} from '../../utils'
|
||||||
|
|
||||||
|
export interface ExecutionVars {
|
||||||
|
[key: string]: string | number | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecuteReturnRaw {
|
||||||
|
httpHeaders: HTTPHeaders
|
||||||
|
result: string | Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExecuteFileParams {
|
||||||
|
programPath: string
|
||||||
|
preProgramVariables: PreProgramVars
|
||||||
|
vars: ExecutionVars
|
||||||
|
otherArgs?: any
|
||||||
|
returnJson?: boolean
|
||||||
|
session?: Session
|
||||||
|
runTime: RunTimeType
|
||||||
|
forceStringResult?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExecuteProgramParams extends Omit<ExecuteFileParams, 'programPath'> {
|
||||||
|
program: string
|
||||||
|
}
|
||||||
|
|
||||||
export class ExecutionController {
|
export class ExecutionController {
|
||||||
async execute(
|
async executeFile({
|
||||||
programPath: string,
|
programPath,
|
||||||
preProgramVariables: PreProgramVars,
|
preProgramVariables,
|
||||||
vars: { [key: string]: string | number | undefined },
|
vars,
|
||||||
otherArgs?: any,
|
otherArgs,
|
||||||
returnJson?: boolean
|
returnJson,
|
||||||
) {
|
session,
|
||||||
if (!(await fileExists(programPath)))
|
runTime,
|
||||||
throw 'ExecutionController: SAS file does not exist.'
|
forceStringResult
|
||||||
|
}: ExecuteFileParams) {
|
||||||
|
const program = await readFile(programPath)
|
||||||
|
|
||||||
let program = await readFile(programPath)
|
return this.executeProgram({
|
||||||
|
program,
|
||||||
const sessionController = getSessionController()
|
preProgramVariables,
|
||||||
|
vars,
|
||||||
const session = await sessionController.getSession()
|
otherArgs,
|
||||||
session.inUse = true
|
returnJson,
|
||||||
|
session,
|
||||||
const logPath = path.join(session.path, 'log.log')
|
runTime,
|
||||||
|
forceStringResult
|
||||||
const weboutPath = path.join(session.path, 'webout.txt')
|
})
|
||||||
await createFile(weboutPath, '')
|
|
||||||
|
|
||||||
const tokenFile = path.join(session.path, 'accessToken.txt')
|
|
||||||
await createFile(
|
|
||||||
tokenFile,
|
|
||||||
preProgramVariables?.accessToken ?? 'accessToken'
|
|
||||||
)
|
|
||||||
|
|
||||||
const varStatments = Object.keys(vars).reduce(
|
|
||||||
(computed: string, key: string) =>
|
|
||||||
`${computed}%let ${key}=${vars[key]};\n`,
|
|
||||||
''
|
|
||||||
)
|
|
||||||
const preProgramVarStatments = `
|
|
||||||
%let _sasjs_tokenfile=${tokenFile};
|
|
||||||
%let _sasjs_username=${preProgramVariables?.username};
|
|
||||||
%let _sasjs_userid=${preProgramVariables?.userId};
|
|
||||||
%let _sasjs_displayname=${preProgramVariables?.displayName};
|
|
||||||
%let _sasjs_apiserverurl=${preProgramVariables?.serverUrl};
|
|
||||||
%let _sasjs_apipath=/SASjsApi/stp/execute;
|
|
||||||
%let _metaperson=&_sasjs_displayname;
|
|
||||||
%let _metauser=&_sasjs_username;
|
|
||||||
%let sasjsprocessmode=Stored Program;`
|
|
||||||
|
|
||||||
program = `
|
|
||||||
/* runtime vars */
|
|
||||||
${varStatments}
|
|
||||||
filename _webout "${weboutPath}" mod;
|
|
||||||
|
|
||||||
/* dynamic user-provided vars */
|
|
||||||
${preProgramVarStatments}
|
|
||||||
|
|
||||||
/* actual job code */
|
|
||||||
${program}`
|
|
||||||
|
|
||||||
// if no files are uploaded filesNamesMap will be undefined
|
|
||||||
if (otherArgs && otherArgs.filesNamesMap) {
|
|
||||||
const uploadSasCode = await generateFileUploadSasCode(
|
|
||||||
otherArgs.filesNamesMap,
|
|
||||||
session.path
|
|
||||||
)
|
|
||||||
|
|
||||||
//If sas code for the file is generated it will be appended to the top of sasCode
|
|
||||||
if (uploadSasCode.length > 0) {
|
|
||||||
program = `${uploadSasCode}` + program
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const codePath = path.join(session.path, 'code.sas')
|
|
||||||
|
|
||||||
// Creating this file in a RUNNING session will break out
|
|
||||||
// the autoexec loop and actually execute the program
|
|
||||||
// but - given it will take several milliseconds to create
|
|
||||||
// (which can mean SAS trying to run a partial program, or
|
|
||||||
// failing due to file lock) we first create the file THEN
|
|
||||||
// we rename it.
|
|
||||||
await createFile(codePath + '.bkp', program)
|
|
||||||
await moveFile(codePath + '.bkp', codePath)
|
|
||||||
|
|
||||||
// we now need to poll the session array
|
|
||||||
while (!session.completed) {
|
|
||||||
await delay(50)
|
|
||||||
}
|
|
||||||
|
|
||||||
const log =
|
|
||||||
((await fileExists(logPath)) ? await readFile(logPath) : '') +
|
|
||||||
session.crashed
|
|
||||||
const webout = (await fileExists(weboutPath))
|
|
||||||
? await readFile(weboutPath)
|
|
||||||
: ''
|
|
||||||
|
|
||||||
const debugValue =
|
|
||||||
typeof vars._debug === 'string' ? parseInt(vars._debug) : vars._debug
|
|
||||||
|
|
||||||
let debugResponse: string | undefined
|
|
||||||
|
|
||||||
if ((debugValue && debugValue >= 131) || session.crashed) {
|
|
||||||
debugResponse = `<html><body>${webout}<div style="text-align:left"><hr /><h2>SAS Log</h2><pre>${log}</pre></div></body></html>`
|
|
||||||
}
|
|
||||||
|
|
||||||
session.inUse = false
|
|
||||||
sessionController.deleteSession(session)
|
|
||||||
|
|
||||||
if (returnJson) {
|
|
||||||
const response: any = {
|
|
||||||
webout: webout
|
|
||||||
}
|
|
||||||
if ((debugValue && debugValue >= 131) || session.crashed) {
|
|
||||||
response.log = log
|
|
||||||
}
|
|
||||||
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
return debugResponse ?? webout
|
|
||||||
}
|
}
|
||||||
|
|
||||||
buildDirectorytree() {
|
async executeProgram({
|
||||||
|
program,
|
||||||
|
preProgramVariables,
|
||||||
|
vars,
|
||||||
|
otherArgs,
|
||||||
|
session: sessionByFileUpload,
|
||||||
|
runTime,
|
||||||
|
forceStringResult
|
||||||
|
}: ExecuteProgramParams): Promise<ExecuteReturnRaw> {
|
||||||
|
const sessionController = getSessionController(runTime)
|
||||||
|
|
||||||
|
const session =
|
||||||
|
sessionByFileUpload ?? (await sessionController.getSession())
|
||||||
|
session.inUse = true
|
||||||
|
session.consumed = true
|
||||||
|
|
||||||
|
const logPath = path.join(session.path, 'log.log')
|
||||||
|
const headersPath = path.join(session.path, 'stpsrv_header.txt')
|
||||||
|
|
||||||
|
const weboutPath = path.join(session.path, 'webout.txt')
|
||||||
|
const tokenFile = path.join(session.path, 'reqHeaders.txt')
|
||||||
|
|
||||||
|
await createFile(weboutPath, '')
|
||||||
|
await createFile(
|
||||||
|
tokenFile,
|
||||||
|
preProgramVariables?.httpHeaders.join('\n') ?? ''
|
||||||
|
)
|
||||||
|
|
||||||
|
await processProgram(
|
||||||
|
program,
|
||||||
|
preProgramVariables,
|
||||||
|
vars,
|
||||||
|
session,
|
||||||
|
weboutPath,
|
||||||
|
headersPath,
|
||||||
|
tokenFile,
|
||||||
|
runTime,
|
||||||
|
logPath,
|
||||||
|
otherArgs
|
||||||
|
)
|
||||||
|
|
||||||
|
const log = (await fileExists(logPath)) ? await readFile(logPath) : ''
|
||||||
|
const headersContent = (await fileExists(headersPath))
|
||||||
|
? await readFile(headersPath)
|
||||||
|
: ''
|
||||||
|
const httpHeaders: HTTPHeaders = extractHeaders(headersContent)
|
||||||
|
|
||||||
|
if (isDebugOn(vars)) {
|
||||||
|
httpHeaders['content-type'] = 'text/plain'
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileResponse: boolean = httpHeaders.hasOwnProperty('content-type')
|
||||||
|
|
||||||
|
const webout = (await fileExists(weboutPath))
|
||||||
|
? fileResponse && !forceStringResult
|
||||||
|
? await readFileBinary(weboutPath)
|
||||||
|
: await readFile(weboutPath)
|
||||||
|
: ''
|
||||||
|
|
||||||
|
// it should be deleted by scheduleSessionDestroy
|
||||||
|
session.inUse = false
|
||||||
|
|
||||||
|
return {
|
||||||
|
httpHeaders,
|
||||||
|
result:
|
||||||
|
isDebugOn(vars) || session.crashed
|
||||||
|
? `${webout}\n${process.logsUUID}\n${log}`
|
||||||
|
: webout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildDirectoryTree() {
|
||||||
const root: TreeNode = {
|
const root: TreeNode = {
|
||||||
name: 'files',
|
name: 'files',
|
||||||
relativePath: '',
|
relativePath: '',
|
||||||
absolutePath: getTmpFilesFolderPath(),
|
absolutePath: getFilesFolder(),
|
||||||
|
isFolder: true,
|
||||||
children: []
|
children: []
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,15 +146,22 @@ ${program}`
|
|||||||
const currentNode = stack.pop()
|
const currentNode = stack.pop()
|
||||||
|
|
||||||
if (currentNode) {
|
if (currentNode) {
|
||||||
|
currentNode.isFolder = fs
|
||||||
|
.statSync(currentNode.absolutePath)
|
||||||
|
.isDirectory()
|
||||||
|
|
||||||
const children = fs.readdirSync(currentNode.absolutePath)
|
const children = fs.readdirSync(currentNode.absolutePath)
|
||||||
|
|
||||||
for (let child of children) {
|
for (let child of children) {
|
||||||
const absoluteChildPath = `${currentNode.absolutePath}/${child}`
|
const absoluteChildPath = path.join(currentNode.absolutePath, child)
|
||||||
|
// relative path will only be used in frontend component
|
||||||
|
// so, no need to convert '/' to platform specific separator
|
||||||
const relativeChildPath = `${currentNode.relativePath}/${child}`
|
const relativeChildPath = `${currentNode.relativePath}/${child}`
|
||||||
const childNode: TreeNode = {
|
const childNode: TreeNode = {
|
||||||
name: child,
|
name: child,
|
||||||
relativePath: relativeChildPath,
|
relativePath: relativeChildPath,
|
||||||
absolutePath: absoluteChildPath,
|
absolutePath: absoluteChildPath,
|
||||||
|
isFolder: false,
|
||||||
children: []
|
children: []
|
||||||
}
|
}
|
||||||
currentNode.children.push(childNode)
|
currentNode.children.push(childNode)
|
||||||
@@ -159,5 +176,3 @@ ${program}`
|
|||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
|
import { Request, RequestHandler } from 'express'
|
||||||
|
import multer from 'multer'
|
||||||
import { uuidv4 } from '@sasjs/utils'
|
import { uuidv4 } from '@sasjs/utils'
|
||||||
import { getSessionController } from '.'
|
import { getSessionController } from '.'
|
||||||
const multer = require('multer')
|
import {
|
||||||
|
executeProgramRawValidation,
|
||||||
|
getRunTimeAndFilePath,
|
||||||
|
RunTimeType
|
||||||
|
} from '../../utils'
|
||||||
|
|
||||||
export class FileUploadController {
|
export class FileUploadController {
|
||||||
private storage = multer.diskStorage({
|
private storage = multer.diskStorage({
|
||||||
destination: function (req: any, file: any, cb: any) {
|
destination: function (req: Request, file: any, cb: any) {
|
||||||
//Sending the intercepted files to the sessions subfolder
|
//Sending the intercepted files to the sessions subfolder
|
||||||
cb(null, req.sasSession.path)
|
cb(null, req.sasjsSession?.path)
|
||||||
},
|
},
|
||||||
filename: function (req: any, file: any, cb: any) {
|
filename: function (req: Request, file: any, cb: any) {
|
||||||
//req_file prefix + unique hash added to sas request files
|
//req_file prefix + unique hash added to sas request files
|
||||||
cb(null, `req_file_${uuidv4().replace(/-/gm, '')}`)
|
cb(null, `req_file_${uuidv4().replace(/-/gm, '')}`)
|
||||||
}
|
}
|
||||||
@@ -18,14 +24,43 @@ export class FileUploadController {
|
|||||||
|
|
||||||
//It will intercept request and generate unique uuid to be used as a subfolder name
|
//It will intercept request and generate unique uuid to be used as a subfolder name
|
||||||
//that will store the files uploaded
|
//that will store the files uploaded
|
||||||
public preuploadMiddleware = async (req: any, res: any, next: any) => {
|
public preUploadMiddleware: RequestHandler = async (req, res, next) => {
|
||||||
let session
|
const { error: errQ, value: query } = executeProgramRawValidation(req.query)
|
||||||
|
const { error: errB, value: body } = executeProgramRawValidation(req.body)
|
||||||
|
|
||||||
const sessionController = getSessionController()
|
if (errQ && errB) return res.status(400).send(errB.details[0].message)
|
||||||
session = await sessionController.getSession()
|
|
||||||
session.inUse = true
|
|
||||||
|
|
||||||
req.sasSession = session
|
const programPath = (query?._program ?? body?._program) as string
|
||||||
|
|
||||||
|
let runTime
|
||||||
|
|
||||||
|
try {
|
||||||
|
;({ runTime } = await getRunTimeAndFilePath(programPath))
|
||||||
|
} catch (err: any) {
|
||||||
|
return res.status(400).send({
|
||||||
|
status: 'failure',
|
||||||
|
message: 'Job execution failed',
|
||||||
|
error: typeof err === 'object' ? err.toString() : err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let sessionController
|
||||||
|
try {
|
||||||
|
sessionController = getSessionController(runTime)
|
||||||
|
} catch (err: any) {
|
||||||
|
return res.status(400).send({
|
||||||
|
status: 'failure',
|
||||||
|
message: err.message,
|
||||||
|
error: typeof err === 'object' ? err.toString() : err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await sessionController.getSession()
|
||||||
|
// marking consumed true, so that it's not available
|
||||||
|
// as readySession for any other request
|
||||||
|
session.consumed = true
|
||||||
|
|
||||||
|
req.sasjsSession = session
|
||||||
|
|
||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,40 +3,79 @@ import { Session } from '../../types'
|
|||||||
import { promisify } from 'util'
|
import { promisify } from 'util'
|
||||||
import { execFile } from 'child_process'
|
import { execFile } from 'child_process'
|
||||||
import {
|
import {
|
||||||
getTmpSessionsFolderPath,
|
getPackagesFolder,
|
||||||
|
getSessionsFolder,
|
||||||
generateUniqueFileName,
|
generateUniqueFileName,
|
||||||
sysInitCompiledPath
|
sysInitCompiledPath,
|
||||||
|
RunTimeType
|
||||||
} from '../../utils'
|
} from '../../utils'
|
||||||
import {
|
import {
|
||||||
deleteFolder,
|
deleteFolder,
|
||||||
createFile,
|
createFile,
|
||||||
fileExists,
|
fileExists,
|
||||||
generateTimestamp,
|
generateTimestamp,
|
||||||
readFile
|
readFile,
|
||||||
|
isWindows
|
||||||
} from '@sasjs/utils'
|
} from '@sasjs/utils'
|
||||||
|
|
||||||
const execFilePromise = promisify(execFile)
|
const execFilePromise = promisify(execFile)
|
||||||
|
|
||||||
export class SessionController {
|
export class SessionController {
|
||||||
private sessions: Session[] = []
|
protected sessions: Session[] = []
|
||||||
|
|
||||||
|
protected getReadySessions = (): Session[] =>
|
||||||
|
this.sessions.filter((sess: Session) => sess.ready && !sess.consumed)
|
||||||
|
|
||||||
|
protected async createSession(): Promise<Session> {
|
||||||
|
const sessionId = generateUniqueFileName(generateTimestamp())
|
||||||
|
const sessionFolder = path.join(getSessionsFolder(), sessionId)
|
||||||
|
|
||||||
|
const creationTimeStamp = sessionId.split('-').pop() as string
|
||||||
|
// death time of session is 15 mins from creation
|
||||||
|
const deathTimeStamp = (
|
||||||
|
parseInt(creationTimeStamp) +
|
||||||
|
15 * 60 * 1000 -
|
||||||
|
1000
|
||||||
|
).toString()
|
||||||
|
|
||||||
|
const session: Session = {
|
||||||
|
id: sessionId,
|
||||||
|
ready: true,
|
||||||
|
inUse: true,
|
||||||
|
consumed: false,
|
||||||
|
completed: false,
|
||||||
|
creationTimeStamp,
|
||||||
|
deathTimeStamp,
|
||||||
|
path: sessionFolder
|
||||||
|
}
|
||||||
|
|
||||||
|
const headersPath = path.join(session.path, 'stpsrv_header.txt')
|
||||||
|
await createFile(headersPath, 'content-type: text/html; charset=utf-8')
|
||||||
|
|
||||||
|
this.sessions.push(session)
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
public async getSession() {
|
public async getSession() {
|
||||||
const readySessions = this.sessions.filter((sess: Session) => sess.ready)
|
const readySessions = this.getReadySessions()
|
||||||
|
|
||||||
const session = readySessions.length
|
const session = readySessions.length
|
||||||
? readySessions[0]
|
? readySessions[0]
|
||||||
: await this.createSession()
|
: await this.createSession()
|
||||||
|
|
||||||
if (readySessions.length < 2) this.createSession()
|
if (readySessions.length < 3) this.createSession()
|
||||||
|
|
||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async createSession() {
|
export class SASSessionController extends SessionController {
|
||||||
|
protected async createSession(): Promise<Session> {
|
||||||
const sessionId = generateUniqueFileName(generateTimestamp())
|
const sessionId = generateUniqueFileName(generateTimestamp())
|
||||||
const sessionFolder = path.join(getTmpSessionsFolderPath(), sessionId)
|
const sessionFolder = path.join(getSessionsFolder(), sessionId)
|
||||||
|
|
||||||
const creationTimeStamp = sessionId.split('-').pop() as string
|
const creationTimeStamp = sessionId.split('-').pop() as string
|
||||||
|
// death time of session is 15 mins from creation
|
||||||
const deathTimeStamp = (
|
const deathTimeStamp = (
|
||||||
parseInt(creationTimeStamp) +
|
parseInt(creationTimeStamp) +
|
||||||
15 * 60 * 1000 -
|
15 * 60 * 1000 -
|
||||||
@@ -47,12 +86,16 @@ export class SessionController {
|
|||||||
id: sessionId,
|
id: sessionId,
|
||||||
ready: false,
|
ready: false,
|
||||||
inUse: false,
|
inUse: false,
|
||||||
|
consumed: false,
|
||||||
completed: false,
|
completed: false,
|
||||||
creationTimeStamp,
|
creationTimeStamp,
|
||||||
deathTimeStamp,
|
deathTimeStamp,
|
||||||
path: sessionFolder
|
path: sessionFolder
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const headersPath = path.join(session.path, 'stpsrv_header.txt')
|
||||||
|
await createFile(headersPath, 'content-type: text/html; charset=utf-8\n')
|
||||||
|
|
||||||
// we do not want to leave sessions running forever
|
// we do not want to leave sessions running forever
|
||||||
// we clean them up after a predefined period, if unused
|
// we clean them up after a predefined period, if unused
|
||||||
this.scheduleSessionDestroy(session)
|
this.scheduleSessionDestroy(session)
|
||||||
@@ -62,7 +105,11 @@ export class SessionController {
|
|||||||
|
|
||||||
// the autoexec file is executed on SAS startup
|
// the autoexec file is executed on SAS startup
|
||||||
const autoExecPath = path.join(sessionFolder, 'autoexec.sas')
|
const autoExecPath = path.join(sessionFolder, 'autoexec.sas')
|
||||||
const contentForAutoExec = `/* compiled systemInit */\n${compiledSystemInitContent}\n/* autoexec */\n${autoExecContent}`
|
const contentForAutoExec = `filename packages "${getPackagesFolder()}";
|
||||||
|
/* compiled systemInit */
|
||||||
|
${compiledSystemInitContent}
|
||||||
|
/* autoexec */
|
||||||
|
${autoExecContent}`
|
||||||
await createFile(autoExecPath, contentForAutoExec)
|
await createFile(autoExecPath, contentForAutoExec)
|
||||||
|
|
||||||
// create empty code.sas as SAS will not start without a SYSIN
|
// create empty code.sas as SAS will not start without a SYSIN
|
||||||
@@ -74,25 +121,37 @@ export class SessionController {
|
|||||||
// however we also need a promise so that we can update the
|
// however we also need a promise so that we can update the
|
||||||
// session array to say that it has (eventually) finished.
|
// session array to say that it has (eventually) finished.
|
||||||
|
|
||||||
execFilePromise(process.sasLoc, [
|
// Additional windows specific options to avoid the desktop popups.
|
||||||
|
|
||||||
|
execFilePromise(process.sasLoc!, [
|
||||||
'-SYSIN',
|
'-SYSIN',
|
||||||
codePath,
|
codePath,
|
||||||
'-LOG',
|
'-LOG',
|
||||||
path.join(session.path, 'log.log'),
|
path.join(session.path, 'log.log'),
|
||||||
|
'-PRINT',
|
||||||
|
path.join(session.path, 'output.lst'),
|
||||||
'-WORK',
|
'-WORK',
|
||||||
session.path,
|
session.path,
|
||||||
'-AUTOEXEC',
|
'-AUTOEXEC',
|
||||||
autoExecPath,
|
autoExecPath,
|
||||||
process.platform === 'win32' ? '-nosplash' : ''
|
isWindows() ? '-nologo' : '',
|
||||||
|
process.sasLoc!.endsWith('sas.exe') ? '-nosplash' : '',
|
||||||
|
process.sasLoc!.endsWith('sas.exe') ? '-icon' : '',
|
||||||
|
process.sasLoc!.endsWith('sas.exe') ? '-nodms' : '',
|
||||||
|
process.sasLoc!.endsWith('sas.exe') ? '-noterminal' : '',
|
||||||
|
process.sasLoc!.endsWith('sas.exe') ? '-nostatuswin' : '',
|
||||||
|
process.sasLoc!.endsWith('sas.exe') ? '-NOPRNGETLIST' : '',
|
||||||
|
process.sasLoc!.endsWith('sas.exe') ? '-SASINITIALFOLDER' : '',
|
||||||
|
process.sasLoc!.endsWith('sas.exe') ? session.path : ''
|
||||||
])
|
])
|
||||||
.then(() => {
|
.then(() => {
|
||||||
session.completed = true
|
session.completed = true
|
||||||
console.log('session completed', session)
|
process.logger.info('session completed', session)
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
session.completed = true
|
session.completed = true
|
||||||
session.crashed = err.toString()
|
session.crashed = err.toString()
|
||||||
console.log('session crashed', session.id)
|
process.logger.error('session crashed', session.id, session.crashed)
|
||||||
})
|
})
|
||||||
|
|
||||||
// we have a triggered session - add to array
|
// we have a triggered session - add to array
|
||||||
@@ -105,33 +164,37 @@ export class SessionController {
|
|||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
public async waitForSession(session: Session) {
|
private async waitForSession(session: Session) {
|
||||||
const codeFilePath = path.join(session.path, 'code.sas')
|
const codeFilePath = path.join(session.path, 'code.sas')
|
||||||
|
|
||||||
// TODO: don't wait forever
|
// TODO: don't wait forever
|
||||||
while ((await fileExists(codeFilePath)) && !session.crashed) {}
|
while ((await fileExists(codeFilePath)) && !session.crashed) {}
|
||||||
console.log('session crashed?', !!session.crashed, session.crashed)
|
|
||||||
|
if (session.crashed)
|
||||||
|
process.logger.error(
|
||||||
|
'session crashed! while waiting to be ready',
|
||||||
|
session.crashed
|
||||||
|
)
|
||||||
|
|
||||||
session.ready = true
|
session.ready = true
|
||||||
return Promise.resolve(session)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deleteSession(session: Session) {
|
private async deleteSession(session: Session) {
|
||||||
// remove the temporary files, to avoid buildup
|
// remove the temporary files, to avoid buildup
|
||||||
await deleteFolder(session.path)
|
await deleteFolder(session.path)
|
||||||
|
|
||||||
// remove the session from the session array
|
// remove the session from the session array
|
||||||
if (session.ready) {
|
this.sessions = this.sessions.filter(
|
||||||
this.sessions = this.sessions.filter(
|
(sess: Session) => sess.id !== session.id
|
||||||
(sess: Session) => sess.id !== session.id
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private scheduleSessionDestroy(session: Session) {
|
private scheduleSessionDestroy(session: Session) {
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
if (session.inUse) {
|
if (session.inUse) {
|
||||||
session.deathTimeStamp = session.deathTimeStamp + 1000 * 10
|
// adding 10 more minutes
|
||||||
|
const newDeathTimeStamp = parseInt(session.deathTimeStamp) + 10 * 1000
|
||||||
|
session.deathTimeStamp = newDeathTimeStamp.toString()
|
||||||
|
|
||||||
this.scheduleSessionDestroy(session)
|
this.scheduleSessionDestroy(session)
|
||||||
} else {
|
} else {
|
||||||
@@ -141,10 +204,18 @@ export class SessionController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getSessionController = (): SessionController => {
|
export const getSessionController = (
|
||||||
if (process.sessionController) return process.sessionController
|
runTime: RunTimeType
|
||||||
|
): SessionController => {
|
||||||
|
if (runTime === RunTimeType.SAS) {
|
||||||
|
process.sasSessionController =
|
||||||
|
process.sasSessionController || new SASSessionController()
|
||||||
|
|
||||||
process.sessionController = new SessionController()
|
return process.sasSessionController
|
||||||
|
}
|
||||||
|
|
||||||
|
process.sessionController =
|
||||||
|
process.sessionController || new SessionController()
|
||||||
|
|
||||||
return process.sessionController
|
return process.sessionController
|
||||||
}
|
}
|
||||||
@@ -153,6 +224,7 @@ const autoExecContent = `
|
|||||||
data _null_;
|
data _null_;
|
||||||
/* remove the dummy SYSIN */
|
/* remove the dummy SYSIN */
|
||||||
length fname $8;
|
length fname $8;
|
||||||
|
call missing(fname);
|
||||||
rc=filename(fname,getoption('SYSIN') );
|
rc=filename(fname,getoption('SYSIN') );
|
||||||
if rc = 0 and fexist(fname) then rc=fdelete(fname);
|
if rc = 0 and fexist(fname) then rc=fdelete(fname);
|
||||||
rc=filename(fname);
|
rc=filename(fname);
|
||||||
|
|||||||
68
api/src/controllers/internal/createJSProgram.ts
Normal file
68
api/src/controllers/internal/createJSProgram.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { escapeWinSlashes } from '@sasjs/utils'
|
||||||
|
import { PreProgramVars, Session } from '../../types'
|
||||||
|
import { generateFileUploadJSCode } from '../../utils'
|
||||||
|
import { ExecutionVars } from './'
|
||||||
|
|
||||||
|
export const createJSProgram = async (
|
||||||
|
program: string,
|
||||||
|
preProgramVariables: PreProgramVars,
|
||||||
|
vars: ExecutionVars,
|
||||||
|
session: Session,
|
||||||
|
weboutPath: string,
|
||||||
|
headersPath: string,
|
||||||
|
tokenFile: string,
|
||||||
|
otherArgs?: any
|
||||||
|
) => {
|
||||||
|
const varStatments = Object.keys(vars).reduce(
|
||||||
|
(computed: string, key: string) =>
|
||||||
|
`${computed}const ${key} = '${vars[key]}';\n`,
|
||||||
|
''
|
||||||
|
)
|
||||||
|
|
||||||
|
const preProgramVarStatments = `
|
||||||
|
let _webout = '';
|
||||||
|
const weboutPath = '${escapeWinSlashes(weboutPath)}';
|
||||||
|
const _SASJS_TOKENFILE = '${escapeWinSlashes(tokenFile)}';
|
||||||
|
const _SASJS_WEBOUT_HEADERS = '${escapeWinSlashes(headersPath)}';
|
||||||
|
const _SASJS_USERNAME = '${preProgramVariables?.username}';
|
||||||
|
const _SASJS_USERID = '${preProgramVariables?.userId}';
|
||||||
|
const _SASJS_DISPLAYNAME = '${preProgramVariables?.displayName}';
|
||||||
|
const _METAPERSON = _SASJS_DISPLAYNAME;
|
||||||
|
const _METAUSER = _SASJS_USERNAME;
|
||||||
|
const SASJSPROCESSMODE = 'Stored Program';
|
||||||
|
`
|
||||||
|
|
||||||
|
const requiredModules = `const fs = require('fs')`
|
||||||
|
|
||||||
|
program = `
|
||||||
|
/* runtime vars */
|
||||||
|
${varStatments}
|
||||||
|
|
||||||
|
/* dynamic user-provided vars */
|
||||||
|
${preProgramVarStatments}
|
||||||
|
|
||||||
|
/* actual job code */
|
||||||
|
${program}
|
||||||
|
|
||||||
|
/* write webout file only if webout exists*/
|
||||||
|
if (_webout) {
|
||||||
|
fs.writeFile(weboutPath, _webout, function (err) {
|
||||||
|
if (err) throw err;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
`
|
||||||
|
// if no files are uploaded filesNamesMap will be undefined
|
||||||
|
if (otherArgs?.filesNamesMap) {
|
||||||
|
const uploadJsCode = await generateFileUploadJSCode(
|
||||||
|
otherArgs.filesNamesMap,
|
||||||
|
session.path
|
||||||
|
)
|
||||||
|
|
||||||
|
// If any files are uploaded, the program needs to be updated with some
|
||||||
|
// dynamically generated variables (pointers) for ease of ingestion
|
||||||
|
if (uploadJsCode.length > 0) {
|
||||||
|
program = `${uploadJsCode}\n` + program
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return requiredModules + program
|
||||||
|
}
|
||||||
64
api/src/controllers/internal/createPythonProgram.ts
Normal file
64
api/src/controllers/internal/createPythonProgram.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { escapeWinSlashes } from '@sasjs/utils'
|
||||||
|
import { PreProgramVars, Session } from '../../types'
|
||||||
|
import { generateFileUploadPythonCode } from '../../utils'
|
||||||
|
import { ExecutionVars } from './'
|
||||||
|
|
||||||
|
export const createPythonProgram = async (
|
||||||
|
program: string,
|
||||||
|
preProgramVariables: PreProgramVars,
|
||||||
|
vars: ExecutionVars,
|
||||||
|
session: Session,
|
||||||
|
weboutPath: string,
|
||||||
|
headersPath: string,
|
||||||
|
tokenFile: string,
|
||||||
|
otherArgs?: any
|
||||||
|
) => {
|
||||||
|
const varStatments = Object.keys(vars).reduce(
|
||||||
|
(computed: string, key: string) => `${computed}${key} = '${vars[key]}';\n`,
|
||||||
|
''
|
||||||
|
)
|
||||||
|
|
||||||
|
const preProgramVarStatments = `
|
||||||
|
_SASJS_SESSION_PATH = '${escapeWinSlashes(session.path)}';
|
||||||
|
_WEBOUT = '${escapeWinSlashes(weboutPath)}';
|
||||||
|
_SASJS_WEBOUT_HEADERS = '${escapeWinSlashes(headersPath)}';
|
||||||
|
_SASJS_TOKENFILE = '${escapeWinSlashes(tokenFile)}';
|
||||||
|
_SASJS_USERNAME = '${preProgramVariables?.username}';
|
||||||
|
_SASJS_USERID = '${preProgramVariables?.userId}';
|
||||||
|
_SASJS_DISPLAYNAME = '${preProgramVariables?.displayName}';
|
||||||
|
_METAPERSON = _SASJS_DISPLAYNAME;
|
||||||
|
_METAUSER = _SASJS_USERNAME;
|
||||||
|
SASJSPROCESSMODE = 'Stored Program';
|
||||||
|
`
|
||||||
|
|
||||||
|
const requiredModules = `import os`
|
||||||
|
|
||||||
|
program = `
|
||||||
|
# runtime vars
|
||||||
|
${varStatments}
|
||||||
|
|
||||||
|
# dynamic user-provided vars
|
||||||
|
${preProgramVarStatments}
|
||||||
|
|
||||||
|
# change working directory to session folder
|
||||||
|
os.chdir(_SASJS_SESSION_PATH)
|
||||||
|
|
||||||
|
# actual job code
|
||||||
|
${program}
|
||||||
|
|
||||||
|
`
|
||||||
|
// if no files are uploaded filesNamesMap will be undefined
|
||||||
|
if (otherArgs?.filesNamesMap) {
|
||||||
|
const uploadPythonCode = await generateFileUploadPythonCode(
|
||||||
|
otherArgs.filesNamesMap,
|
||||||
|
session.path
|
||||||
|
)
|
||||||
|
|
||||||
|
// If any files are uploaded, the program needs to be updated with some
|
||||||
|
// dynamically generated variables (pointers) for ease of ingestion
|
||||||
|
if (uploadPythonCode.length > 0) {
|
||||||
|
program = `${uploadPythonCode}\n` + program
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return requiredModules + program
|
||||||
|
}
|
||||||
64
api/src/controllers/internal/createRProgram.ts
Normal file
64
api/src/controllers/internal/createRProgram.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { escapeWinSlashes } from '@sasjs/utils'
|
||||||
|
import { PreProgramVars, Session } from '../../types'
|
||||||
|
import { generateFileUploadRCode } from '../../utils'
|
||||||
|
import { ExecutionVars } from '.'
|
||||||
|
|
||||||
|
export const createRProgram = async (
|
||||||
|
program: string,
|
||||||
|
preProgramVariables: PreProgramVars,
|
||||||
|
vars: ExecutionVars,
|
||||||
|
session: Session,
|
||||||
|
weboutPath: string,
|
||||||
|
headersPath: string,
|
||||||
|
tokenFile: string,
|
||||||
|
otherArgs?: any
|
||||||
|
) => {
|
||||||
|
const varStatments = Object.keys(vars).reduce(
|
||||||
|
(computed: string, key: string) => `${computed}.${key} <- '${vars[key]}'\n`,
|
||||||
|
''
|
||||||
|
)
|
||||||
|
|
||||||
|
const preProgramVarStatments = `
|
||||||
|
._SASJS_SESSION_PATH <- '${escapeWinSlashes(session.path)}';
|
||||||
|
._WEBOUT <- '${escapeWinSlashes(weboutPath)}';
|
||||||
|
._SASJS_WEBOUT_HEADERS <- '${escapeWinSlashes(headersPath)}';
|
||||||
|
._SASJS_TOKENFILE <- '${escapeWinSlashes(tokenFile)}';
|
||||||
|
._SASJS_USERNAME <- '${preProgramVariables?.username}';
|
||||||
|
._SASJS_USERID <- '${preProgramVariables?.userId}';
|
||||||
|
._SASJS_DISPLAYNAME <- '${preProgramVariables?.displayName}';
|
||||||
|
._METAPERSON <- ._SASJS_DISPLAYNAME;
|
||||||
|
._METAUSER <- ._SASJS_USERNAME;
|
||||||
|
SASJSPROCESSMODE <- 'Stored Program';
|
||||||
|
`
|
||||||
|
|
||||||
|
const requiredModules = ``
|
||||||
|
|
||||||
|
program = `
|
||||||
|
# runtime vars
|
||||||
|
${varStatments}
|
||||||
|
|
||||||
|
# dynamic user-provided vars
|
||||||
|
${preProgramVarStatments}
|
||||||
|
|
||||||
|
# change working directory to session folder
|
||||||
|
setwd(._SASJS_SESSION_PATH)
|
||||||
|
|
||||||
|
# actual job code
|
||||||
|
${program}
|
||||||
|
|
||||||
|
`
|
||||||
|
// if no files are uploaded filesNamesMap will be undefined
|
||||||
|
if (otherArgs?.filesNamesMap) {
|
||||||
|
const uploadRCode = await generateFileUploadRCode(
|
||||||
|
otherArgs.filesNamesMap,
|
||||||
|
session.path
|
||||||
|
)
|
||||||
|
|
||||||
|
// If any files are uploaded, the program needs to be updated with some
|
||||||
|
// dynamically generated variables (pointers) for ease of ingestion
|
||||||
|
if (uploadRCode.length > 0) {
|
||||||
|
program = `${uploadRCode}\n` + program
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return requiredModules + program
|
||||||
|
}
|
||||||
78
api/src/controllers/internal/createSASProgram.ts
Normal file
78
api/src/controllers/internal/createSASProgram.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { PreProgramVars, Session } from '../../types'
|
||||||
|
import { generateFileUploadSasCode, getMacrosFolder } from '../../utils'
|
||||||
|
import { ExecutionVars } from './'
|
||||||
|
|
||||||
|
export const createSASProgram = async (
|
||||||
|
program: string,
|
||||||
|
preProgramVariables: PreProgramVars,
|
||||||
|
vars: ExecutionVars,
|
||||||
|
session: Session,
|
||||||
|
weboutPath: string,
|
||||||
|
headersPath: string,
|
||||||
|
tokenFile: string,
|
||||||
|
otherArgs?: any
|
||||||
|
) => {
|
||||||
|
const varStatments = Object.keys(vars).reduce(
|
||||||
|
(computed: string, key: string) => `${computed}%let ${key}=${vars[key]};\n`,
|
||||||
|
''
|
||||||
|
)
|
||||||
|
|
||||||
|
const preProgramVarStatments = `
|
||||||
|
%let _sasjs_tokenfile=${tokenFile};
|
||||||
|
%let _sasjs_username=${preProgramVariables?.username};
|
||||||
|
%let _sasjs_userid=${preProgramVariables?.userId};
|
||||||
|
%let _sasjs_displayname=${preProgramVariables?.displayName};
|
||||||
|
%let _sasjs_apiserverurl=${preProgramVariables?.serverUrl};
|
||||||
|
%let _sasjs_apipath=/SASjsApi/stp/execute;
|
||||||
|
%let _sasjs_webout_headers=${headersPath};
|
||||||
|
%let _metaperson=&_sasjs_displayname;
|
||||||
|
%let _metauser=&_sasjs_username;
|
||||||
|
|
||||||
|
/* the below is here for compatibility and will be removed in a future release */
|
||||||
|
%let sasjs_stpsrv_header_loc=&_sasjs_webout_headers;
|
||||||
|
|
||||||
|
%let sasjsprocessmode=Stored Program;
|
||||||
|
|
||||||
|
%global SYSPROCESSMODE SYSTCPIPHOSTNAME SYSHOSTINFOLONG;
|
||||||
|
%macro _sasjs_server_init();
|
||||||
|
%if "&SYSPROCESSMODE"="" %then %let SYSPROCESSMODE=&sasjsprocessmode;
|
||||||
|
%if "&SYSTCPIPHOSTNAME"="" %then %let SYSTCPIPHOSTNAME=&_sasjs_apiserverurl;
|
||||||
|
%mend;
|
||||||
|
%_sasjs_server_init()
|
||||||
|
|
||||||
|
proc printto print="%sysfunc(getoption(log))";
|
||||||
|
run;
|
||||||
|
`
|
||||||
|
|
||||||
|
program = `
|
||||||
|
options insert=(SASAUTOS="${getMacrosFolder()}");
|
||||||
|
|
||||||
|
/* runtime vars */
|
||||||
|
${varStatments}
|
||||||
|
filename _webout "${weboutPath}" mod;
|
||||||
|
|
||||||
|
/* dynamic user-provided vars */
|
||||||
|
${preProgramVarStatments}
|
||||||
|
|
||||||
|
/* user autoexec starts */
|
||||||
|
${otherArgs?.userAutoExec ?? ''}
|
||||||
|
/* user autoexec ends */
|
||||||
|
|
||||||
|
/* actual job code */
|
||||||
|
${program}`
|
||||||
|
|
||||||
|
// if no files are uploaded filesNamesMap will be undefined
|
||||||
|
if (otherArgs?.filesNamesMap) {
|
||||||
|
const uploadSasCode = await generateFileUploadSasCode(
|
||||||
|
otherArgs.filesNamesMap,
|
||||||
|
session.path
|
||||||
|
)
|
||||||
|
|
||||||
|
// If any files are uploaded, the program needs to be updated with some
|
||||||
|
// dynamically generated variables (pointers) for ease of ingestion
|
||||||
|
if (uploadSasCode.length > 0) {
|
||||||
|
program = `${uploadSasCode}` + program
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return program
|
||||||
|
}
|
||||||
@@ -1,37 +1,52 @@
|
|||||||
import { MemberType, FolderMember, ServiceMember, FileTree } from '../../types'
|
|
||||||
import { getTmpFilesFolderPath } from '../../utils/file'
|
|
||||||
import { createFolder, createFile, asyncForEach } from '@sasjs/utils'
|
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
import { getFilesFolder } from '../../utils/file'
|
||||||
|
import {
|
||||||
|
createFolder,
|
||||||
|
createFile,
|
||||||
|
asyncForEach,
|
||||||
|
FolderMember,
|
||||||
|
ServiceMember,
|
||||||
|
FileMember,
|
||||||
|
MemberType,
|
||||||
|
FileTree
|
||||||
|
} from '@sasjs/utils'
|
||||||
|
|
||||||
// REFACTOR: export FileTreeCpntroller
|
// REFACTOR: export FileTreeCpntroller
|
||||||
export const createFileTree = async (
|
export const createFileTree = async (
|
||||||
members: (FolderMember | ServiceMember)[],
|
members: (FolderMember | ServiceMember | FileMember)[],
|
||||||
parentFolders: string[] = []
|
parentFolders: string[] = []
|
||||||
) => {
|
) => {
|
||||||
const destinationPath = path.join(
|
const destinationPath = path.join(
|
||||||
getTmpFilesFolderPath(),
|
getFilesFolder(),
|
||||||
path.join(...parentFolders)
|
path.join(...parentFolders)
|
||||||
)
|
)
|
||||||
|
|
||||||
await asyncForEach(members, async (member: FolderMember | ServiceMember) => {
|
await asyncForEach(
|
||||||
let name = member.name
|
members,
|
||||||
|
async (member: FolderMember | ServiceMember | FileMember) => {
|
||||||
|
let name = member.name
|
||||||
|
|
||||||
if (member.type === MemberType.service) name += '.sas'
|
if (member.type === MemberType.service) name += '.sas'
|
||||||
|
|
||||||
if (member.type === MemberType.folder) {
|
if (member.type === MemberType.folder) {
|
||||||
await createFolder(path.join(destinationPath, name)).catch((err) =>
|
await createFolder(path.join(destinationPath, name)).catch((err) =>
|
||||||
Promise.reject({ error: err, failedToCreate: name })
|
Promise.reject({ error: err, failedToCreate: name })
|
||||||
)
|
)
|
||||||
|
|
||||||
await createFileTree(member.members, [...parentFolders, name]).catch(
|
await createFileTree(member.members, [...parentFolders, name]).catch(
|
||||||
(err) => Promise.reject({ error: err, failedToCreate: name })
|
(err) => Promise.reject({ error: err, failedToCreate: name })
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
await createFile(path.join(destinationPath, name), member.code).catch(
|
const encoding = member.type === MemberType.file ? 'base64' : undefined
|
||||||
(err) => Promise.reject({ error: err, failedToCreate: name })
|
|
||||||
)
|
await createFile(
|
||||||
|
path.join(destinationPath, name),
|
||||||
|
member.code,
|
||||||
|
encoding
|
||||||
|
).catch((err) => Promise.reject({ error: err, failedToCreate: name }))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
|
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,3 +2,8 @@ export * from './deploy'
|
|||||||
export * from './Session'
|
export * from './Session'
|
||||||
export * from './Execution'
|
export * from './Execution'
|
||||||
export * from './FileUploadController'
|
export * from './FileUploadController'
|
||||||
|
export * from './createSASProgram'
|
||||||
|
export * from './createJSProgram'
|
||||||
|
export * from './createPythonProgram'
|
||||||
|
export * from './createRProgram'
|
||||||
|
export * from './processProgram'
|
||||||
|
|||||||
162
api/src/controllers/internal/processProgram.ts
Normal file
162
api/src/controllers/internal/processProgram.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import path from 'path'
|
||||||
|
import { WriteStream, createWriteStream } from 'fs'
|
||||||
|
import { execFile } from 'child_process'
|
||||||
|
import { once } from 'stream'
|
||||||
|
import { createFile, moveFile } from '@sasjs/utils'
|
||||||
|
import { PreProgramVars, Session } from '../../types'
|
||||||
|
import { RunTimeType } from '../../utils'
|
||||||
|
import {
|
||||||
|
ExecutionVars,
|
||||||
|
createSASProgram,
|
||||||
|
createJSProgram,
|
||||||
|
createPythonProgram,
|
||||||
|
createRProgram
|
||||||
|
} from './'
|
||||||
|
|
||||||
|
export const processProgram = async (
|
||||||
|
program: string,
|
||||||
|
preProgramVariables: PreProgramVars,
|
||||||
|
vars: ExecutionVars,
|
||||||
|
session: Session,
|
||||||
|
weboutPath: string,
|
||||||
|
headersPath: string,
|
||||||
|
tokenFile: string,
|
||||||
|
runTime: RunTimeType,
|
||||||
|
logPath: string,
|
||||||
|
otherArgs?: any
|
||||||
|
) => {
|
||||||
|
if (runTime === RunTimeType.SAS) {
|
||||||
|
program = await createSASProgram(
|
||||||
|
program,
|
||||||
|
preProgramVariables,
|
||||||
|
vars,
|
||||||
|
session,
|
||||||
|
weboutPath,
|
||||||
|
headersPath,
|
||||||
|
tokenFile,
|
||||||
|
otherArgs
|
||||||
|
)
|
||||||
|
|
||||||
|
const codePath = path.join(session.path, 'code.sas')
|
||||||
|
|
||||||
|
// Creating this file in a RUNNING session will break out
|
||||||
|
// the autoexec loop and actually execute the program
|
||||||
|
// but - given it will take several milliseconds to create
|
||||||
|
// (which can mean SAS trying to run a partial program, or
|
||||||
|
// failing due to file lock) we first create the file THEN
|
||||||
|
// we rename it.
|
||||||
|
await createFile(codePath + '.bkp', program)
|
||||||
|
await moveFile(codePath + '.bkp', codePath)
|
||||||
|
|
||||||
|
// we now need to poll the session status
|
||||||
|
while (!session.completed) {
|
||||||
|
await delay(50)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let codePath: string
|
||||||
|
let executablePath: string
|
||||||
|
switch (runTime) {
|
||||||
|
case RunTimeType.JS:
|
||||||
|
program = await createJSProgram(
|
||||||
|
program,
|
||||||
|
preProgramVariables,
|
||||||
|
vars,
|
||||||
|
session,
|
||||||
|
weboutPath,
|
||||||
|
headersPath,
|
||||||
|
tokenFile,
|
||||||
|
otherArgs
|
||||||
|
)
|
||||||
|
codePath = path.join(session.path, 'code.js')
|
||||||
|
executablePath = process.nodeLoc!
|
||||||
|
|
||||||
|
break
|
||||||
|
case RunTimeType.PY:
|
||||||
|
program = await createPythonProgram(
|
||||||
|
program,
|
||||||
|
preProgramVariables,
|
||||||
|
vars,
|
||||||
|
session,
|
||||||
|
weboutPath,
|
||||||
|
headersPath,
|
||||||
|
tokenFile,
|
||||||
|
otherArgs
|
||||||
|
)
|
||||||
|
codePath = path.join(session.path, 'code.py')
|
||||||
|
executablePath = process.pythonLoc!
|
||||||
|
|
||||||
|
break
|
||||||
|
case RunTimeType.R:
|
||||||
|
program = await createRProgram(
|
||||||
|
program,
|
||||||
|
preProgramVariables,
|
||||||
|
vars,
|
||||||
|
session,
|
||||||
|
weboutPath,
|
||||||
|
headersPath,
|
||||||
|
tokenFile,
|
||||||
|
otherArgs
|
||||||
|
)
|
||||||
|
codePath = path.join(session.path, 'code.r')
|
||||||
|
executablePath = process.rLoc!
|
||||||
|
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
throw new Error('Invalid runtime!')
|
||||||
|
}
|
||||||
|
|
||||||
|
await createFile(codePath, program)
|
||||||
|
|
||||||
|
// create a stream that will write to console outputs to log file
|
||||||
|
const writeStream = createWriteStream(logPath)
|
||||||
|
// waiting for the open event so that we can have underlying file descriptor
|
||||||
|
await once(writeStream, 'open')
|
||||||
|
|
||||||
|
await execFilePromise(executablePath, [codePath], writeStream)
|
||||||
|
.then(() => {
|
||||||
|
session.completed = true
|
||||||
|
process.logger.info('session completed', session)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
session.completed = true
|
||||||
|
session.crashed = err.toString()
|
||||||
|
process.logger.error('session crashed', session.id, session.crashed)
|
||||||
|
})
|
||||||
|
|
||||||
|
// copy the code file to log and end write stream
|
||||||
|
writeStream.end(program)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Promisified child_process.execFile
|
||||||
|
*
|
||||||
|
* @param file - The name or path of the executable file to run.
|
||||||
|
* @param args - List of string arguments.
|
||||||
|
* @param writeStream - Child process stdout and stderr will be piped to it.
|
||||||
|
*
|
||||||
|
* @returns {Promise<{ stdout: string, stderr: string }>}
|
||||||
|
*/
|
||||||
|
const execFilePromise = (
|
||||||
|
file: string,
|
||||||
|
args: string[],
|
||||||
|
writeStream: WriteStream
|
||||||
|
): Promise<{ stdout: string; stderr: string }> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = execFile(file, args, (err, stdout, stderr) => {
|
||||||
|
if (err) reject(err)
|
||||||
|
|
||||||
|
resolve({ stdout, stderr })
|
||||||
|
})
|
||||||
|
|
||||||
|
child.stdout?.on('data', (data) => {
|
||||||
|
writeStream.write(data)
|
||||||
|
})
|
||||||
|
|
||||||
|
child.stderr?.on('data', (data) => {
|
||||||
|
writeStream.write(data)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
283
api/src/controllers/mock-sas9.ts
Normal file
283
api/src/controllers/mock-sas9.ts
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import { readFile } from '@sasjs/utils'
|
||||||
|
import express from 'express'
|
||||||
|
import path from 'path'
|
||||||
|
import { Request, Post, Get } from 'tsoa'
|
||||||
|
import dotenv from 'dotenv'
|
||||||
|
import { ExecutionController } from './internal'
|
||||||
|
import {
|
||||||
|
getPreProgramVariables,
|
||||||
|
getRunTimeAndFilePath,
|
||||||
|
makeFilesNamesMap
|
||||||
|
} from '../utils'
|
||||||
|
import { MulterFile } from '../types/Upload'
|
||||||
|
|
||||||
|
dotenv.config()
|
||||||
|
|
||||||
|
export interface Sas9Response {
|
||||||
|
content: string
|
||||||
|
redirect?: string
|
||||||
|
error?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MockFileRead {
|
||||||
|
content: string
|
||||||
|
error?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MockSas9Controller {
|
||||||
|
private loggedIn: string | undefined
|
||||||
|
private mocksPath = process.env.STATIC_MOCK_LOCATION || 'mocks'
|
||||||
|
|
||||||
|
@Get('/SASStoredProcess')
|
||||||
|
public async sasStoredProcess(
|
||||||
|
@Request() req: express.Request
|
||||||
|
): Promise<Sas9Response> {
|
||||||
|
const username = req.query._username?.toString() || undefined
|
||||||
|
const password = req.query._password?.toString() || undefined
|
||||||
|
|
||||||
|
if (username && password) this.loggedIn = req.body.username
|
||||||
|
|
||||||
|
if (!this.loggedIn) {
|
||||||
|
return {
|
||||||
|
content: '',
|
||||||
|
redirect: '/SASLogon/login'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let program = req.query._program?.toString() || undefined
|
||||||
|
const filePath: string[] = program
|
||||||
|
? program.replace('/', '').split('/')
|
||||||
|
: ['generic', 'sas-stored-process']
|
||||||
|
|
||||||
|
if (program) {
|
||||||
|
return await getMockResponseFromFile([
|
||||||
|
process.cwd(),
|
||||||
|
this.mocksPath,
|
||||||
|
'sas9',
|
||||||
|
...filePath
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
return await getMockResponseFromFile([
|
||||||
|
process.cwd(),
|
||||||
|
'mocks',
|
||||||
|
'sas9',
|
||||||
|
...filePath
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/SASStoredProcess/do')
|
||||||
|
public async sasStoredProcessDoGet(
|
||||||
|
@Request() req: express.Request
|
||||||
|
): Promise<Sas9Response> {
|
||||||
|
const username = req.query._username?.toString() || undefined
|
||||||
|
const password = req.query._password?.toString() || undefined
|
||||||
|
|
||||||
|
if (username && password) this.loggedIn = username
|
||||||
|
|
||||||
|
if (!this.loggedIn) {
|
||||||
|
return {
|
||||||
|
content: '',
|
||||||
|
redirect: '/SASLogon/login'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const program = req.query._program ?? req.body?._program
|
||||||
|
const filePath: string[] = ['generic', 'sas-stored-process']
|
||||||
|
|
||||||
|
if (program) {
|
||||||
|
const vars = { ...req.query, ...req.body, _requestMethod: req.method }
|
||||||
|
const otherArgs = {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { codePath, runTime } = await getRunTimeAndFilePath(
|
||||||
|
program + '.js'
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await new ExecutionController().executeFile({
|
||||||
|
programPath: codePath,
|
||||||
|
preProgramVariables: getPreProgramVariables(req),
|
||||||
|
vars: vars,
|
||||||
|
otherArgs: otherArgs,
|
||||||
|
runTime,
|
||||||
|
forceStringResult: true
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: result.result as string
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
process.logger.error('err', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: 'No webout returned.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await getMockResponseFromFile([
|
||||||
|
process.cwd(),
|
||||||
|
'mocks',
|
||||||
|
'sas9',
|
||||||
|
...filePath
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('/SASStoredProcess/do/')
|
||||||
|
public async sasStoredProcessDoPost(
|
||||||
|
@Request() req: express.Request
|
||||||
|
): Promise<Sas9Response> {
|
||||||
|
if (!this.loggedIn) {
|
||||||
|
return {
|
||||||
|
content: '',
|
||||||
|
redirect: '/SASLogon/login'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isPublicAccount()) {
|
||||||
|
return {
|
||||||
|
content: '',
|
||||||
|
redirect: '/SASLogon/Login'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const program = req.query._program ?? req.body?._program
|
||||||
|
const vars = {
|
||||||
|
...req.query,
|
||||||
|
...req.body,
|
||||||
|
_requestMethod: req.method,
|
||||||
|
_driveLoc: process.driveLoc
|
||||||
|
}
|
||||||
|
const filesNamesMap = req.files?.length
|
||||||
|
? makeFilesNamesMap(req.files as MulterFile[])
|
||||||
|
: null
|
||||||
|
const otherArgs = { filesNamesMap: filesNamesMap }
|
||||||
|
const { codePath, runTime } = await getRunTimeAndFilePath(program + '.js')
|
||||||
|
try {
|
||||||
|
const result = await new ExecutionController().executeFile({
|
||||||
|
programPath: codePath,
|
||||||
|
preProgramVariables: getPreProgramVariables(req),
|
||||||
|
vars: vars,
|
||||||
|
otherArgs: otherArgs,
|
||||||
|
runTime,
|
||||||
|
session: req.sasjsSession,
|
||||||
|
forceStringResult: true
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: result.result as string
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
process.logger.error('err', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: 'No webout returned.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/SASLogon/login')
|
||||||
|
public async loginGet(): Promise<Sas9Response> {
|
||||||
|
if (this.loggedIn) {
|
||||||
|
if (this.isPublicAccount()) {
|
||||||
|
return {
|
||||||
|
content: '',
|
||||||
|
redirect: '/SASStoredProcess/Logoff?publicDenied=true'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return await getMockResponseFromFile([
|
||||||
|
process.cwd(),
|
||||||
|
'mocks',
|
||||||
|
'sas9',
|
||||||
|
'generic',
|
||||||
|
'logged-in'
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await getMockResponseFromFile([
|
||||||
|
process.cwd(),
|
||||||
|
'mocks',
|
||||||
|
'sas9',
|
||||||
|
'generic',
|
||||||
|
'login'
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('/SASLogon/login')
|
||||||
|
public async loginPost(req: express.Request): Promise<Sas9Response> {
|
||||||
|
if (req.body.lt && req.body.lt !== 'validtoken')
|
||||||
|
return {
|
||||||
|
content: '',
|
||||||
|
redirect: '/SASLogon/login'
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loggedIn = req.body.username
|
||||||
|
|
||||||
|
return await getMockResponseFromFile([
|
||||||
|
process.cwd(),
|
||||||
|
'mocks',
|
||||||
|
'sas9',
|
||||||
|
'generic',
|
||||||
|
'logged-in'
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/SASLogon/logout')
|
||||||
|
public async logout(req: express.Request): Promise<Sas9Response> {
|
||||||
|
this.loggedIn = undefined
|
||||||
|
|
||||||
|
if (req.query.publicDenied === 'true') {
|
||||||
|
return await getMockResponseFromFile([
|
||||||
|
process.cwd(),
|
||||||
|
'mocks',
|
||||||
|
'sas9',
|
||||||
|
'generic',
|
||||||
|
'public-access-denied'
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
return await getMockResponseFromFile([
|
||||||
|
process.cwd(),
|
||||||
|
'mocks',
|
||||||
|
'sas9',
|
||||||
|
'generic',
|
||||||
|
'logged-out'
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/SASStoredProcess/Logoff') //publicDenied=true
|
||||||
|
public async logoff(req: express.Request): Promise<Sas9Response> {
|
||||||
|
const params = req.query.publicDenied
|
||||||
|
? `?publicDenied=${req.query.publicDenied}`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: '',
|
||||||
|
redirect: '/SASLogon/logout' + params
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isPublicAccount = () => this.loggedIn?.toLowerCase() === 'public'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMockResponseFromFile = async (
|
||||||
|
filePath: string[]
|
||||||
|
): Promise<MockFileRead> => {
|
||||||
|
const filePathParsed = path.join(...filePath)
|
||||||
|
let error: boolean = false
|
||||||
|
|
||||||
|
let file = await readFile(filePathParsed).catch((err: any) => {
|
||||||
|
const errMsg = `Error reading mocked file on path: ${filePathParsed}\nError: ${err}`
|
||||||
|
process.logger.error(errMsg)
|
||||||
|
|
||||||
|
error = true
|
||||||
|
|
||||||
|
return errMsg
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: file,
|
||||||
|
error: error
|
||||||
|
}
|
||||||
|
}
|
||||||
368
api/src/controllers/permission.ts
Normal file
368
api/src/controllers/permission.ts
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
import express from 'express'
|
||||||
|
import {
|
||||||
|
Security,
|
||||||
|
Route,
|
||||||
|
Tags,
|
||||||
|
Path,
|
||||||
|
Example,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Patch,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Request
|
||||||
|
} from 'tsoa'
|
||||||
|
|
||||||
|
import Permission from '../model/Permission'
|
||||||
|
import User from '../model/User'
|
||||||
|
import Group from '../model/Group'
|
||||||
|
import { UserResponse } from './user'
|
||||||
|
import { GroupDetailsResponse } from './group'
|
||||||
|
|
||||||
|
export enum PermissionType {
|
||||||
|
route = 'Route'
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum PrincipalType {
|
||||||
|
user = 'user',
|
||||||
|
group = 'group'
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum PermissionSettingForRoute {
|
||||||
|
grant = 'Grant',
|
||||||
|
deny = 'Deny'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RegisterPermissionPayload {
|
||||||
|
/**
|
||||||
|
* Name of affected resource
|
||||||
|
* @example "/SASjsApi/code/execute"
|
||||||
|
*/
|
||||||
|
path: string
|
||||||
|
/**
|
||||||
|
* Type of affected resource
|
||||||
|
* @example "Route"
|
||||||
|
*/
|
||||||
|
type: PermissionType
|
||||||
|
/**
|
||||||
|
* The indication of whether (and to what extent) access is provided
|
||||||
|
* @example "Grant"
|
||||||
|
*/
|
||||||
|
setting: PermissionSettingForRoute
|
||||||
|
/**
|
||||||
|
* Indicates the type of principal
|
||||||
|
* @example "user"
|
||||||
|
*/
|
||||||
|
principalType: PrincipalType
|
||||||
|
/**
|
||||||
|
* The id of user or group to which a rule is assigned.
|
||||||
|
* @example 123
|
||||||
|
*/
|
||||||
|
principalId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdatePermissionPayload {
|
||||||
|
/**
|
||||||
|
* The indication of whether (and to what extent) access is provided
|
||||||
|
* @example "Grant"
|
||||||
|
*/
|
||||||
|
setting: PermissionSettingForRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PermissionDetailsResponse {
|
||||||
|
permissionId: number
|
||||||
|
path: string
|
||||||
|
type: string
|
||||||
|
setting: string
|
||||||
|
user?: UserResponse
|
||||||
|
group?: GroupDetailsResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
@Security('bearerAuth')
|
||||||
|
@Route('SASjsApi/permission')
|
||||||
|
@Tags('Permission')
|
||||||
|
export class PermissionController {
|
||||||
|
/**
|
||||||
|
* Get the list of permission rules applicable the authenticated user.
|
||||||
|
* If the user is an admin, all rules are returned.
|
||||||
|
*
|
||||||
|
* @summary Get the list of permission rules. If the user is admin, all rules are returned.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
@Example<PermissionDetailsResponse[]>([
|
||||||
|
{
|
||||||
|
permissionId: 123,
|
||||||
|
path: '/SASjsApi/code/execute',
|
||||||
|
type: 'Route',
|
||||||
|
setting: 'Grant',
|
||||||
|
user: {
|
||||||
|
id: 1,
|
||||||
|
username: 'johnSnow01',
|
||||||
|
displayName: 'John Snow',
|
||||||
|
isAdmin: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
permissionId: 124,
|
||||||
|
path: '/SASjsApi/code/execute',
|
||||||
|
type: 'Route',
|
||||||
|
setting: 'Grant',
|
||||||
|
group: {
|
||||||
|
groupId: 1,
|
||||||
|
name: 'DCGroup',
|
||||||
|
description: 'This group represents Data Controller Users',
|
||||||
|
isActive: true,
|
||||||
|
users: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])
|
||||||
|
@Get('/')
|
||||||
|
public async getAllPermissions(
|
||||||
|
@Request() request: express.Request
|
||||||
|
): Promise<PermissionDetailsResponse[]> {
|
||||||
|
return getAllPermissions(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Create a new permission. Admin only.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
@Example<PermissionDetailsResponse>({
|
||||||
|
permissionId: 123,
|
||||||
|
path: '/SASjsApi/code/execute',
|
||||||
|
type: 'Route',
|
||||||
|
setting: 'Grant',
|
||||||
|
user: {
|
||||||
|
id: 1,
|
||||||
|
username: 'johnSnow01',
|
||||||
|
displayName: 'John Snow',
|
||||||
|
isAdmin: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
@Post('/')
|
||||||
|
public async createPermission(
|
||||||
|
@Body() body: RegisterPermissionPayload
|
||||||
|
): Promise<PermissionDetailsResponse> {
|
||||||
|
return createPermission(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Update permission setting. Admin only
|
||||||
|
* @param permissionId The permission's identifier
|
||||||
|
* @example permissionId 1234
|
||||||
|
*/
|
||||||
|
@Example<PermissionDetailsResponse>({
|
||||||
|
permissionId: 123,
|
||||||
|
path: '/SASjsApi/code/execute',
|
||||||
|
type: 'Route',
|
||||||
|
setting: 'Grant',
|
||||||
|
user: {
|
||||||
|
id: 1,
|
||||||
|
username: 'johnSnow01',
|
||||||
|
displayName: 'John Snow',
|
||||||
|
isAdmin: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
@Patch('{permissionId}')
|
||||||
|
public async updatePermission(
|
||||||
|
@Path() permissionId: number,
|
||||||
|
@Body() body: UpdatePermissionPayload
|
||||||
|
): Promise<PermissionDetailsResponse> {
|
||||||
|
return updatePermission(permissionId, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Delete a permission. Admin only.
|
||||||
|
* @param permissionId The user's identifier
|
||||||
|
* @example permissionId 1234
|
||||||
|
*/
|
||||||
|
@Delete('{permissionId}')
|
||||||
|
public async deletePermission(@Path() permissionId: number) {
|
||||||
|
return deletePermission(permissionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAllPermissions = async (
|
||||||
|
req: express.Request
|
||||||
|
): Promise<PermissionDetailsResponse[]> => {
|
||||||
|
const { user } = req
|
||||||
|
|
||||||
|
if (user?.isAdmin) return await Permission.get({})
|
||||||
|
else {
|
||||||
|
const permissions: PermissionDetailsResponse[] = []
|
||||||
|
|
||||||
|
const dbUser = await User.findOne({ id: user?.userId })
|
||||||
|
if (!dbUser)
|
||||||
|
throw {
|
||||||
|
code: 404,
|
||||||
|
status: 'Not Found',
|
||||||
|
message: 'User not found.'
|
||||||
|
}
|
||||||
|
|
||||||
|
permissions.push(...(await Permission.get({ user: dbUser._id })))
|
||||||
|
|
||||||
|
for (const group of dbUser.groups) {
|
||||||
|
permissions.push(...(await Permission.get({ group })))
|
||||||
|
}
|
||||||
|
|
||||||
|
return permissions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createPermission = async ({
|
||||||
|
path,
|
||||||
|
type,
|
||||||
|
setting,
|
||||||
|
principalType,
|
||||||
|
principalId
|
||||||
|
}: RegisterPermissionPayload): Promise<PermissionDetailsResponse> => {
|
||||||
|
const permission = new Permission({
|
||||||
|
path,
|
||||||
|
type,
|
||||||
|
setting
|
||||||
|
})
|
||||||
|
|
||||||
|
let user: UserResponse | undefined
|
||||||
|
let group: GroupDetailsResponse | undefined
|
||||||
|
|
||||||
|
switch (principalType) {
|
||||||
|
case PrincipalType.user: {
|
||||||
|
const userInDB = await User.findOne({ id: principalId })
|
||||||
|
if (!userInDB)
|
||||||
|
throw {
|
||||||
|
code: 404,
|
||||||
|
status: 'Not Found',
|
||||||
|
message: 'User not found.'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userInDB.isAdmin)
|
||||||
|
throw {
|
||||||
|
code: 400,
|
||||||
|
status: 'Bad Request',
|
||||||
|
message: 'Can not add permission for admin user.'
|
||||||
|
}
|
||||||
|
|
||||||
|
const alreadyExists = await Permission.findOne({
|
||||||
|
path,
|
||||||
|
type,
|
||||||
|
user: userInDB._id
|
||||||
|
})
|
||||||
|
|
||||||
|
if (alreadyExists)
|
||||||
|
throw {
|
||||||
|
code: 409,
|
||||||
|
status: 'Conflict',
|
||||||
|
message:
|
||||||
|
'Permission already exists with provided Path, Type and User.'
|
||||||
|
}
|
||||||
|
|
||||||
|
permission.user = userInDB._id
|
||||||
|
|
||||||
|
user = {
|
||||||
|
id: userInDB.id,
|
||||||
|
username: userInDB.username,
|
||||||
|
displayName: userInDB.displayName,
|
||||||
|
isAdmin: userInDB.isAdmin
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case PrincipalType.group: {
|
||||||
|
const groupInDB = await Group.findOne({ groupId: principalId })
|
||||||
|
if (!groupInDB)
|
||||||
|
throw {
|
||||||
|
code: 404,
|
||||||
|
status: 'Not Found',
|
||||||
|
message: 'Group not found.'
|
||||||
|
}
|
||||||
|
|
||||||
|
const alreadyExists = await Permission.findOne({
|
||||||
|
path,
|
||||||
|
type,
|
||||||
|
group: groupInDB._id
|
||||||
|
})
|
||||||
|
if (alreadyExists)
|
||||||
|
throw {
|
||||||
|
code: 409,
|
||||||
|
status: 'Conflict',
|
||||||
|
message:
|
||||||
|
'Permission already exists with provided Path, Type and Group.'
|
||||||
|
}
|
||||||
|
|
||||||
|
permission.group = groupInDB._id
|
||||||
|
|
||||||
|
group = {
|
||||||
|
groupId: groupInDB.groupId,
|
||||||
|
name: groupInDB.name,
|
||||||
|
description: groupInDB.description,
|
||||||
|
isActive: groupInDB.isActive,
|
||||||
|
users: groupInDB.populate({
|
||||||
|
path: 'users',
|
||||||
|
select: 'id username displayName isAdmin -_id',
|
||||||
|
options: { limit: 15 }
|
||||||
|
}) as unknown as UserResponse[]
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw {
|
||||||
|
code: 400,
|
||||||
|
status: 'Bad Request',
|
||||||
|
message: 'Invalid principal type. Valid types are user or group.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedPermission = await permission.save()
|
||||||
|
|
||||||
|
return {
|
||||||
|
permissionId: savedPermission.permissionId,
|
||||||
|
path: savedPermission.path,
|
||||||
|
type: savedPermission.type,
|
||||||
|
setting: savedPermission.setting,
|
||||||
|
user,
|
||||||
|
group
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePermission = async (
|
||||||
|
id: number,
|
||||||
|
data: UpdatePermissionPayload
|
||||||
|
): Promise<PermissionDetailsResponse> => {
|
||||||
|
const { setting } = data
|
||||||
|
|
||||||
|
const updatedPermission = (await Permission.findOneAndUpdate(
|
||||||
|
{ permissionId: id },
|
||||||
|
{ setting },
|
||||||
|
{ new: true }
|
||||||
|
)
|
||||||
|
.select({
|
||||||
|
_id: 0,
|
||||||
|
permissionId: 1,
|
||||||
|
path: 1,
|
||||||
|
type: 1,
|
||||||
|
setting: 1
|
||||||
|
})
|
||||||
|
.populate({ path: 'user', select: 'id username displayName isAdmin -_id' })
|
||||||
|
.populate({
|
||||||
|
path: 'group',
|
||||||
|
select: 'groupId name description -_id'
|
||||||
|
})) as unknown as PermissionDetailsResponse
|
||||||
|
if (!updatedPermission)
|
||||||
|
throw {
|
||||||
|
code: 404,
|
||||||
|
status: 'Not Found',
|
||||||
|
message: 'Permission not found.'
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedPermission
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletePermission = async (id: number) => {
|
||||||
|
const permission = await Permission.findOne({ permissionId: id })
|
||||||
|
if (!permission)
|
||||||
|
throw {
|
||||||
|
code: 404,
|
||||||
|
status: 'Not Found',
|
||||||
|
message: 'Permission not found.'
|
||||||
|
}
|
||||||
|
await Permission.deleteOne({ permissionId: id })
|
||||||
|
}
|
||||||
37
api/src/controllers/session.ts
Normal file
37
api/src/controllers/session.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import express from 'express'
|
||||||
|
import { Request, Security, Route, Tags, Example, Get } from 'tsoa'
|
||||||
|
import { UserResponse } from './user'
|
||||||
|
|
||||||
|
interface SessionResponse extends UserResponse {
|
||||||
|
needsToUpdatePassword: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
@Security('bearerAuth')
|
||||||
|
@Route('SASjsApi/session')
|
||||||
|
@Tags('Session')
|
||||||
|
export class SessionController {
|
||||||
|
/**
|
||||||
|
* @summary Get session info (username).
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
@Example<UserResponse>({
|
||||||
|
id: 123,
|
||||||
|
username: 'johnusername',
|
||||||
|
displayName: 'John',
|
||||||
|
isAdmin: false
|
||||||
|
})
|
||||||
|
@Get('/')
|
||||||
|
public async session(
|
||||||
|
@Request() request: express.Request
|
||||||
|
): Promise<SessionResponse> {
|
||||||
|
return session(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = (req: express.Request) => ({
|
||||||
|
id: req.user!.userId,
|
||||||
|
username: req.user!.username,
|
||||||
|
displayName: req.user!.displayName,
|
||||||
|
isAdmin: req.user!.isAdmin,
|
||||||
|
needsToUpdatePassword: req.user!.needsToUpdatePassword
|
||||||
|
})
|
||||||
@@ -1,94 +1,103 @@
|
|||||||
import express, { response } from 'express'
|
import express from 'express'
|
||||||
import path from 'path'
|
import { Request, Security, Route, Tags, Post, Body, Get, Query } from 'tsoa'
|
||||||
|
import { ExecutionController, ExecutionVars } from './internal'
|
||||||
import {
|
import {
|
||||||
Request,
|
getPreProgramVariables,
|
||||||
Security,
|
HTTPHeaders,
|
||||||
Route,
|
LogLine,
|
||||||
Tags,
|
makeFilesNamesMap,
|
||||||
Example,
|
getRunTimeAndFilePath
|
||||||
Post,
|
} from '../utils'
|
||||||
Body,
|
import { MulterFile } from '../types/Upload'
|
||||||
Get,
|
|
||||||
Query
|
|
||||||
} from 'tsoa'
|
|
||||||
import { ExecutionController } from './internal'
|
|
||||||
import { PreProgramVars } from '../types'
|
|
||||||
import { getTmpFilesFolderPath, makeFilesNamesMap } from '../utils'
|
|
||||||
|
|
||||||
interface ExecuteReturnJsonPayload {
|
interface ExecutePostRequestPayload {
|
||||||
/**
|
/**
|
||||||
* Location of SAS program
|
* Location of SAS program
|
||||||
* @example "/Public/somefolder/some.file"
|
* @example "/Public/somefolder/some.file"
|
||||||
*/
|
*/
|
||||||
_program?: string
|
_program?: string
|
||||||
}
|
}
|
||||||
interface ExecuteReturnJsonResponse {
|
|
||||||
status: string
|
|
||||||
log?: string
|
|
||||||
_webout?: string
|
|
||||||
message?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
@Security('bearerAuth')
|
@Security('bearerAuth')
|
||||||
@Route('SASjsApi/stp')
|
@Route('SASjsApi/stp')
|
||||||
@Tags('STP')
|
@Tags('STP')
|
||||||
export class STPController {
|
export class STPController {
|
||||||
/**
|
/**
|
||||||
* Trigger a SAS program using it's location in the _program parameter.
|
* Trigger a Stored Program using the _program URL parameter.
|
||||||
* Enable debugging using the _debug parameter.
|
*
|
||||||
* Additional URL parameters are turned into SAS macro variables.
|
* Accepts URL parameters and file uploads. For more details, see docs:
|
||||||
* Any files provided are placed into the session and
|
*
|
||||||
* corresponding _WEBIN_XXX variables are created.
|
* https://server.sasjs.io/storedprograms
|
||||||
* @summary Execute Stored Program, return raw content
|
*
|
||||||
* @query _program Location of SAS program
|
* @summary Execute a Stored Program, returns _webout and (optionally) log.
|
||||||
* @example _program "/Public/somefolder/some.file"
|
* @param _program Location of code in SASjs Drive
|
||||||
|
* @example _program "/Projects/myApp/some/program"
|
||||||
*/
|
*/
|
||||||
@Get('/execute')
|
@Get('/execute')
|
||||||
public async executeReturnRaw(
|
public async executeGetRequest(
|
||||||
@Request() request: express.Request,
|
@Request() request: express.Request,
|
||||||
@Query() _program: string
|
@Query() _program: string
|
||||||
): Promise<string> {
|
): Promise<string | Buffer> {
|
||||||
return executeReturnRaw(request, _program)
|
const vars = request.query as ExecutionVars
|
||||||
|
return execute(request, _program, vars)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trigger a SAS program using it's location in the _program parameter.
|
* Trigger a Stored Program using the _program URL parameter.
|
||||||
* Enable debugging using the _debug parameter.
|
*
|
||||||
* Additional URL parameters are turned into SAS macro variables.
|
* Accepts URL parameters and file uploads. For more details, see docs:
|
||||||
* Any files provided are placed into the session and
|
*
|
||||||
* corresponding _WEBIN_XXX variables are created.
|
* https://server.sasjs.io/storedprograms
|
||||||
* @summary Execute Stored Program, return JSON
|
*
|
||||||
* @query _program Location of SAS program
|
*
|
||||||
* @example _program "/Public/somefolder/some.file"
|
* @summary Execute a Stored Program, returns _webout and (optionally) log.
|
||||||
|
* @param _program Location of code in SASjs Drive
|
||||||
|
* @example _program "/Projects/myApp/some/program"
|
||||||
*/
|
*/
|
||||||
@Post('/execute')
|
@Post('/execute')
|
||||||
public async executeReturnJson(
|
public async executePostRequest(
|
||||||
@Request() request: express.Request,
|
@Request() request: express.Request,
|
||||||
@Body() body?: ExecuteReturnJsonPayload,
|
@Body() body?: ExecutePostRequestPayload,
|
||||||
@Query() _program?: string
|
@Query() _program?: string
|
||||||
): Promise<ExecuteReturnJsonResponse> {
|
): Promise<string | Buffer> {
|
||||||
const program = _program ?? body?._program
|
const program = _program ?? body?._program
|
||||||
return executeReturnJson(request, program!)
|
const vars = { ...request.query, ...request.body }
|
||||||
|
const filesNamesMap = request.files?.length
|
||||||
|
? makeFilesNamesMap(request.files as MulterFile[])
|
||||||
|
: null
|
||||||
|
const otherArgs = { filesNamesMap: filesNamesMap }
|
||||||
|
|
||||||
|
return execute(request, program!, vars, otherArgs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const executeReturnRaw = async (
|
const execute = async (
|
||||||
req: express.Request,
|
req: express.Request,
|
||||||
_program: string
|
_program: string,
|
||||||
): Promise<string> => {
|
vars: ExecutionVars,
|
||||||
const query = req.query as { [key: string]: string | number | undefined }
|
otherArgs?: any
|
||||||
const sasCodePath =
|
): Promise<string | Buffer> => {
|
||||||
path
|
|
||||||
.join(getTmpFilesFolderPath(), _program)
|
|
||||||
.replace(new RegExp('/', 'g'), path.sep) + '.sas'
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await new ExecutionController().execute(
|
const { codePath, runTime } = await getRunTimeAndFilePath(_program)
|
||||||
sasCodePath,
|
|
||||||
getPreProgramVariables(req),
|
const { result, httpHeaders } = await new ExecutionController().executeFile(
|
||||||
query
|
{
|
||||||
|
programPath: codePath,
|
||||||
|
runTime,
|
||||||
|
preProgramVariables: getPreProgramVariables(req),
|
||||||
|
vars,
|
||||||
|
otherArgs,
|
||||||
|
session: req.sasjsSession
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return result as string
|
req.res?.header(httpHeaders)
|
||||||
|
|
||||||
|
if (result instanceof Buffer) {
|
||||||
|
;(req as any).sasHeaders = httpHeaders
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
throw {
|
throw {
|
||||||
code: 400,
|
code: 400,
|
||||||
@@ -98,50 +107,3 @@ const executeReturnRaw = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const executeReturnJson = async (
|
|
||||||
req: any,
|
|
||||||
_program: string
|
|
||||||
): Promise<ExecuteReturnJsonResponse> => {
|
|
||||||
const sasCodePath =
|
|
||||||
path
|
|
||||||
.join(getTmpFilesFolderPath(), _program)
|
|
||||||
.replace(new RegExp('/', 'g'), path.sep) + '.sas'
|
|
||||||
|
|
||||||
const filesNamesMap = req.files?.length ? makeFilesNamesMap(req.files) : null
|
|
||||||
|
|
||||||
try {
|
|
||||||
const jsonResult: any = await new ExecutionController().execute(
|
|
||||||
sasCodePath,
|
|
||||||
getPreProgramVariables(req),
|
|
||||||
{ ...req.query, ...req.body },
|
|
||||||
{ filesNamesMap: filesNamesMap },
|
|
||||||
true
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
status: 'success',
|
|
||||||
_webout: jsonResult.webout,
|
|
||||||
log: jsonResult.log
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
throw {
|
|
||||||
code: 400,
|
|
||||||
status: 'failure',
|
|
||||||
message: 'Job execution failed.',
|
|
||||||
error: typeof err === 'object' ? err.toString() : err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getPreProgramVariables = (req: any): PreProgramVars => {
|
|
||||||
const host = req.get('host')
|
|
||||||
const protocol = req.protocol + '://'
|
|
||||||
const { user, accessToken } = req
|
|
||||||
return {
|
|
||||||
username: user.username,
|
|
||||||
userId: user.userId,
|
|
||||||
displayName: user.displayName,
|
|
||||||
serverUrl: protocol + host,
|
|
||||||
accessToken
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import express from 'express'
|
||||||
import {
|
import {
|
||||||
Security,
|
Security,
|
||||||
Route,
|
Route,
|
||||||
@@ -10,23 +11,35 @@ import {
|
|||||||
Patch,
|
Patch,
|
||||||
Delete,
|
Delete,
|
||||||
Body,
|
Body,
|
||||||
Hidden
|
Hidden,
|
||||||
|
Request
|
||||||
} from 'tsoa'
|
} from 'tsoa'
|
||||||
|
import { desktopUser } from '../middlewares'
|
||||||
|
|
||||||
import User, { UserPayload } from '../model/User'
|
import User, { UserPayload } from '../model/User'
|
||||||
|
import {
|
||||||
|
getUserAutoExec,
|
||||||
|
updateUserAutoExec,
|
||||||
|
ModeType,
|
||||||
|
ALL_USERS_GROUP
|
||||||
|
} from '../utils'
|
||||||
|
import { GroupController, GroupResponse } from './group'
|
||||||
|
|
||||||
export interface UserResponse {
|
export interface UserResponse {
|
||||||
id: number
|
id: number
|
||||||
username: string
|
username: string
|
||||||
displayName: string
|
displayName: string
|
||||||
|
isAdmin: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserDetailsResponse {
|
export interface UserDetailsResponse {
|
||||||
id: number
|
id: number
|
||||||
displayName: string
|
displayName: string
|
||||||
username: string
|
username: string
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
|
autoExec?: string
|
||||||
|
groups?: GroupResponse[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@Security('bearerAuth')
|
@Security('bearerAuth')
|
||||||
@@ -41,12 +54,14 @@ export class UserController {
|
|||||||
{
|
{
|
||||||
id: 123,
|
id: 123,
|
||||||
username: 'johnusername',
|
username: 'johnusername',
|
||||||
displayName: 'John'
|
displayName: 'John',
|
||||||
|
isAdmin: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 456,
|
id: 456,
|
||||||
username: 'starkusername',
|
username: 'starkusername',
|
||||||
displayName: 'Stark'
|
displayName: 'Stark',
|
||||||
|
isAdmin: true
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
@Get('/')
|
@Get('/')
|
||||||
@@ -73,13 +88,68 @@ export class UserController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Only Admin or user itself will get user autoExec code.
|
||||||
|
* @summary Get user properties - such as group memberships, userName, displayName.
|
||||||
|
* @param username The User's username
|
||||||
|
* @example username "johnSnow01"
|
||||||
|
*/
|
||||||
|
@Get('by/username/{username}')
|
||||||
|
public async getUserByUsername(
|
||||||
|
@Request() req: express.Request,
|
||||||
|
@Path() username: string
|
||||||
|
): Promise<UserDetailsResponse> {
|
||||||
|
const { MODE } = process.env
|
||||||
|
|
||||||
|
if (MODE === ModeType.Desktop) return getDesktopAutoExec()
|
||||||
|
|
||||||
|
const { user } = req
|
||||||
|
const getAutoExec = user!.isAdmin || user!.username == username
|
||||||
|
return getUser({ username }, getAutoExec)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only Admin or user itself will get user autoExec code.
|
||||||
* @summary Get user properties - such as group memberships, userName, displayName.
|
* @summary Get user properties - such as group memberships, userName, displayName.
|
||||||
* @param userId The user's identifier
|
* @param userId The user's identifier
|
||||||
* @example userId 1234
|
* @example userId 1234
|
||||||
*/
|
*/
|
||||||
@Get('{userId}')
|
@Get('{userId}')
|
||||||
public async getUser(@Path() userId: number): Promise<UserDetailsResponse> {
|
public async getUser(
|
||||||
return getUser(userId)
|
@Request() req: express.Request,
|
||||||
|
@Path() userId: number
|
||||||
|
): Promise<UserDetailsResponse> {
|
||||||
|
const { MODE } = process.env
|
||||||
|
|
||||||
|
if (MODE === ModeType.Desktop) return getDesktopAutoExec()
|
||||||
|
|
||||||
|
const { user } = req
|
||||||
|
const getAutoExec = user!.isAdmin || user!.userId == userId
|
||||||
|
return getUser({ id: userId }, getAutoExec)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Update user properties - such as displayName. Can be performed either by admins, or the user in question.
|
||||||
|
* @param username The User's username
|
||||||
|
* @example username "johnSnow01"
|
||||||
|
*/
|
||||||
|
@Example<UserDetailsResponse>({
|
||||||
|
id: 1234,
|
||||||
|
displayName: 'John Snow',
|
||||||
|
username: 'johnSnow01',
|
||||||
|
isAdmin: false,
|
||||||
|
isActive: true
|
||||||
|
})
|
||||||
|
@Patch('by/username/{username}')
|
||||||
|
public async updateUserByUsername(
|
||||||
|
@Path() username: string,
|
||||||
|
@Body() body: UserPayload
|
||||||
|
): Promise<UserDetailsResponse> {
|
||||||
|
const { MODE } = process.env
|
||||||
|
|
||||||
|
if (MODE === ModeType.Desktop)
|
||||||
|
return updateDesktopAutoExec(body.autoExec ?? '')
|
||||||
|
|
||||||
|
return updateUser({ username }, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,7 +169,26 @@ export class UserController {
|
|||||||
@Path() userId: number,
|
@Path() userId: number,
|
||||||
@Body() body: UserPayload
|
@Body() body: UserPayload
|
||||||
): Promise<UserDetailsResponse> {
|
): Promise<UserDetailsResponse> {
|
||||||
return updateUser(userId, body)
|
const { MODE } = process.env
|
||||||
|
|
||||||
|
if (MODE === ModeType.Desktop)
|
||||||
|
return updateDesktopAutoExec(body.autoExec ?? '')
|
||||||
|
|
||||||
|
return updateUser({ id: userId }, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Delete a user. Can be performed either by admins, or the user in question.
|
||||||
|
* @param username The User's username
|
||||||
|
* @example username "johnSnow01"
|
||||||
|
*/
|
||||||
|
@Delete('by/username/{username}')
|
||||||
|
public async deleteUserByUsername(
|
||||||
|
@Path() username: string,
|
||||||
|
@Body() body: { password?: string },
|
||||||
|
@Query() @Hidden() isAdmin: boolean = false
|
||||||
|
) {
|
||||||
|
return deleteUser({ username }, isAdmin, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -113,21 +202,25 @@ export class UserController {
|
|||||||
@Body() body: { password?: string },
|
@Body() body: { password?: string },
|
||||||
@Query() @Hidden() isAdmin: boolean = false
|
@Query() @Hidden() isAdmin: boolean = false
|
||||||
) {
|
) {
|
||||||
return deleteUser(userId, isAdmin, body)
|
return deleteUser({ id: userId }, isAdmin, body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAllUsers = async (): Promise<UserResponse[]> =>
|
const getAllUsers = async (): Promise<UserResponse[]> =>
|
||||||
await User.find({})
|
await User.find({})
|
||||||
.select({ _id: 0, id: 1, username: 1, displayName: 1 })
|
.select({ _id: 0, id: 1, username: 1, displayName: 1, isAdmin: 1 })
|
||||||
.exec()
|
.exec()
|
||||||
|
|
||||||
const createUser = async (data: UserPayload): Promise<UserDetailsResponse> => {
|
const createUser = async (data: UserPayload): Promise<UserDetailsResponse> => {
|
||||||
const { displayName, username, password, isAdmin, isActive } = data
|
const { displayName, username, password, isAdmin, isActive, autoExec } = data
|
||||||
|
|
||||||
// Checking if user is already in the database
|
// Checking if user is already in the database
|
||||||
const usernameExist = await User.findOne({ username })
|
const usernameExist = await User.findOne({ username })
|
||||||
if (usernameExist) throw new Error('Username already exists.')
|
if (usernameExist)
|
||||||
|
throw {
|
||||||
|
code: 409,
|
||||||
|
message: 'Username already exists.'
|
||||||
|
}
|
||||||
|
|
||||||
// Hash passwords
|
// Hash passwords
|
||||||
const hashPassword = User.hashPassword(password)
|
const hashPassword = User.hashPassword(password)
|
||||||
@@ -138,48 +231,112 @@ const createUser = async (data: UserPayload): Promise<UserDetailsResponse> => {
|
|||||||
username,
|
username,
|
||||||
password: hashPassword,
|
password: hashPassword,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
isActive
|
isActive,
|
||||||
|
autoExec
|
||||||
})
|
})
|
||||||
|
|
||||||
const savedUser = await user.save()
|
const savedUser = await user.save()
|
||||||
|
|
||||||
|
const groupController = new GroupController()
|
||||||
|
const allUsersGroup = await groupController
|
||||||
|
.getGroupByGroupName(ALL_USERS_GROUP.name)
|
||||||
|
.catch(() => {})
|
||||||
|
|
||||||
|
if (allUsersGroup) {
|
||||||
|
await groupController.addUserToGroup(allUsersGroup.groupId, savedUser.id)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: savedUser.id,
|
id: savedUser.id,
|
||||||
displayName: savedUser.displayName,
|
displayName: savedUser.displayName,
|
||||||
username: savedUser.username,
|
username: savedUser.username,
|
||||||
isActive: savedUser.isActive,
|
isActive: savedUser.isActive,
|
||||||
isAdmin: savedUser.isAdmin
|
isAdmin: savedUser.isAdmin,
|
||||||
|
autoExec: savedUser.autoExec
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getUser = async (id: number): Promise<UserDetailsResponse> => {
|
interface GetUserBy {
|
||||||
const user = await User.findOne({ id })
|
id?: number
|
||||||
.select({
|
username?: string
|
||||||
_id: 0,
|
}
|
||||||
id: 1,
|
|
||||||
username: 1,
|
|
||||||
displayName: 1,
|
|
||||||
isAdmin: 1,
|
|
||||||
isActive: 1
|
|
||||||
})
|
|
||||||
.exec()
|
|
||||||
if (!user) throw new Error('User is not found.')
|
|
||||||
|
|
||||||
return user
|
const getUser = async (
|
||||||
|
findBy: GetUserBy,
|
||||||
|
getAutoExec: boolean
|
||||||
|
): Promise<UserDetailsResponse> => {
|
||||||
|
const user = (await User.findOne(
|
||||||
|
findBy,
|
||||||
|
`id displayName username isActive isAdmin autoExec -_id`
|
||||||
|
).populate(
|
||||||
|
'groups',
|
||||||
|
'groupId name description -_id'
|
||||||
|
)) as unknown as UserDetailsResponse
|
||||||
|
|
||||||
|
if (!user)
|
||||||
|
throw {
|
||||||
|
code: 404,
|
||||||
|
message: 'User is not found.'
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
displayName: user.displayName,
|
||||||
|
username: user.username,
|
||||||
|
isActive: user.isActive,
|
||||||
|
isAdmin: user.isAdmin,
|
||||||
|
autoExec: getAutoExec ? user.autoExec ?? '' : undefined,
|
||||||
|
groups: user.groups
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDesktopAutoExec = async () => {
|
||||||
|
return {
|
||||||
|
...desktopUser,
|
||||||
|
id: desktopUser.userId,
|
||||||
|
autoExec: await getUserAutoExec()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateUser = async (
|
const updateUser = async (
|
||||||
id: number,
|
findBy: GetUserBy,
|
||||||
data: UserPayload
|
data: Partial<UserPayload>
|
||||||
): Promise<UserDetailsResponse> => {
|
): Promise<UserDetailsResponse> => {
|
||||||
const { displayName, username, password, isAdmin, isActive } = data
|
const { displayName, username, password, isAdmin, isActive, autoExec } = data
|
||||||
|
|
||||||
const params: any = { displayName, isAdmin, isActive }
|
const params: any = { displayName, isAdmin, isActive, autoExec }
|
||||||
|
|
||||||
|
const user = await User.findOne(findBy)
|
||||||
|
|
||||||
|
if (username && username !== user?.username && user?.authProvider) {
|
||||||
|
throw {
|
||||||
|
code: 405,
|
||||||
|
message:
|
||||||
|
'Can not update username of user that is created by an external auth provider.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayName && displayName !== user?.displayName && user?.authProvider) {
|
||||||
|
throw {
|
||||||
|
code: 405,
|
||||||
|
message:
|
||||||
|
'Can not update display name of user that is created by an external auth provider.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (username) {
|
if (username) {
|
||||||
// Checking if user is already in the database
|
// Checking if user is already in the database
|
||||||
const usernameExist = await User.findOne({ username })
|
const usernameExist = await User.findOne({ username })
|
||||||
if (usernameExist?.id != id) throw new Error('Username already exists.')
|
if (usernameExist) {
|
||||||
|
if (
|
||||||
|
(findBy.id && usernameExist.id != findBy.id) ||
|
||||||
|
(findBy.username && usernameExist.username != findBy.username)
|
||||||
|
)
|
||||||
|
throw {
|
||||||
|
code: 409,
|
||||||
|
message: 'Username already exists.'
|
||||||
|
}
|
||||||
|
}
|
||||||
params.username = username
|
params.username = username
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,33 +345,53 @@ const updateUser = async (
|
|||||||
params.password = User.hashPassword(password)
|
params.password = User.hashPassword(password)
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedUser = await User.findOneAndUpdate({ id }, params, { new: true })
|
const updatedUser = await User.findOneAndUpdate(findBy, params, { new: true })
|
||||||
.select({
|
|
||||||
_id: 0,
|
|
||||||
id: 1,
|
|
||||||
username: 1,
|
|
||||||
displayName: 1,
|
|
||||||
isAdmin: 1,
|
|
||||||
isActive: 1
|
|
||||||
})
|
|
||||||
.exec()
|
|
||||||
if (!updatedUser) throw new Error('Unable to update user')
|
|
||||||
|
|
||||||
return updatedUser
|
if (!updatedUser)
|
||||||
|
throw {
|
||||||
|
code: 404,
|
||||||
|
message: `Unable to find user with ${findBy.id || findBy.username}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: updatedUser.id,
|
||||||
|
username: updatedUser.username,
|
||||||
|
displayName: updatedUser.displayName,
|
||||||
|
isAdmin: updatedUser.isAdmin,
|
||||||
|
isActive: updatedUser.isActive,
|
||||||
|
autoExec: updatedUser.autoExec
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateDesktopAutoExec = async (autoExec: string) => {
|
||||||
|
await updateUserAutoExec(autoExec)
|
||||||
|
return {
|
||||||
|
...desktopUser,
|
||||||
|
id: desktopUser.userId,
|
||||||
|
autoExec
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteUser = async (
|
const deleteUser = async (
|
||||||
id: number,
|
findBy: GetUserBy,
|
||||||
isAdmin: boolean,
|
isAdmin: boolean,
|
||||||
{ password }: { password?: string }
|
{ password }: { password?: string }
|
||||||
) => {
|
) => {
|
||||||
const user = await User.findOne({ id })
|
const user = await User.findOne(findBy)
|
||||||
if (!user) throw new Error('User is not found.')
|
if (!user)
|
||||||
|
throw {
|
||||||
|
code: 404,
|
||||||
|
message: 'User is not found.'
|
||||||
|
}
|
||||||
|
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
const validPass = user.comparePassword(password!)
|
const validPass = user.comparePassword(password!)
|
||||||
if (!validPass) throw new Error('Invalid password.')
|
if (!validPass)
|
||||||
|
throw {
|
||||||
|
code: 401,
|
||||||
|
message: 'Invalid password.'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await User.deleteOne({ id })
|
await User.deleteOne(findBy)
|
||||||
}
|
}
|
||||||
|
|||||||
209
api/src/controllers/web.ts
Normal file
209
api/src/controllers/web.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import path from 'path'
|
||||||
|
import express from 'express'
|
||||||
|
import { Request, Route, Tags, Post, Body, Get, Example } from 'tsoa'
|
||||||
|
import { readFile, convertSecondsToHms } from '@sasjs/utils'
|
||||||
|
|
||||||
|
import User from '../model/User'
|
||||||
|
import Client from '../model/Client'
|
||||||
|
import {
|
||||||
|
getWebBuildFolder,
|
||||||
|
generateAuthCode,
|
||||||
|
RateLimiter,
|
||||||
|
AuthProviderType,
|
||||||
|
LDAPClient
|
||||||
|
} from '../utils'
|
||||||
|
import { InfoJWT } from '../types'
|
||||||
|
import { AuthController } from './auth'
|
||||||
|
|
||||||
|
@Route('/')
|
||||||
|
@Tags('Web')
|
||||||
|
export class WebController {
|
||||||
|
/**
|
||||||
|
* @summary Render index.html
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
@Get('/')
|
||||||
|
public async home() {
|
||||||
|
return home()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Accept a valid username/password
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
@Post('/SASLogon/login')
|
||||||
|
public async login(
|
||||||
|
@Request() req: express.Request,
|
||||||
|
@Body() body: LoginPayload
|
||||||
|
) {
|
||||||
|
return login(req, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Accept a valid username/password, plus a CLIENT_ID, and return an AUTH_CODE
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
@Example<AuthorizeResponse>({
|
||||||
|
code: 'someRandomCryptoString'
|
||||||
|
})
|
||||||
|
@Post('/SASLogon/authorize')
|
||||||
|
public async authorize(
|
||||||
|
@Request() req: express.Request,
|
||||||
|
@Body() body: AuthorizePayload
|
||||||
|
): Promise<AuthorizeResponse> {
|
||||||
|
return authorize(req, body.clientId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Destroy the session stored in cookies
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
@Get('/SASLogon/logout')
|
||||||
|
public async logout(@Request() req: express.Request) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
req.session.destroy(() => {
|
||||||
|
resolve(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const home = async () => {
|
||||||
|
const indexHtmlPath = path.join(getWebBuildFolder(), 'index.html')
|
||||||
|
|
||||||
|
// Attention! Cannot use fileExists here,
|
||||||
|
// due to limitation after building executable
|
||||||
|
const content = await readFile(indexHtmlPath)
|
||||||
|
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
const login = async (
|
||||||
|
req: express.Request,
|
||||||
|
{ username, password }: LoginPayload
|
||||||
|
) => {
|
||||||
|
// Authenticate User
|
||||||
|
const user = await User.findOne({ username })
|
||||||
|
|
||||||
|
let validPass = false
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
if (
|
||||||
|
process.env.AUTH_PROVIDERS === AuthProviderType.LDAP &&
|
||||||
|
user.authProvider === AuthProviderType.LDAP
|
||||||
|
) {
|
||||||
|
const ldapClient = await LDAPClient.init()
|
||||||
|
validPass = await ldapClient
|
||||||
|
.verifyUser(username, password)
|
||||||
|
.catch(() => false)
|
||||||
|
} else {
|
||||||
|
validPass = user.comparePassword(password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// code to prevent brute force attack
|
||||||
|
|
||||||
|
const rateLimiter = RateLimiter.getInstance()
|
||||||
|
|
||||||
|
if (!validPass) {
|
||||||
|
const retrySecs = await rateLimiter.consume(req.ip, user?.username)
|
||||||
|
if (retrySecs > 0) throw errors.tooManyRequests(retrySecs)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) throw errors.userNotFound
|
||||||
|
if (!validPass) throw errors.invalidPassword
|
||||||
|
|
||||||
|
// Reset on successful authorization
|
||||||
|
rateLimiter.resetOnSuccess(req.ip, user.username)
|
||||||
|
|
||||||
|
req.session.loggedIn = true
|
||||||
|
req.session.user = {
|
||||||
|
userId: user.id,
|
||||||
|
clientId: 'web_app',
|
||||||
|
username: user.username,
|
||||||
|
displayName: user.displayName,
|
||||||
|
isAdmin: user.isAdmin,
|
||||||
|
isActive: user.isActive,
|
||||||
|
autoExec: user.autoExec,
|
||||||
|
needsToUpdatePassword: user.needsToUpdatePassword
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
loggedIn: true,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
displayName: user.displayName,
|
||||||
|
isAdmin: user.isAdmin,
|
||||||
|
needsToUpdatePassword: user.needsToUpdatePassword
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorize = async (
|
||||||
|
req: express.Request,
|
||||||
|
clientId: string
|
||||||
|
): Promise<AuthorizeResponse> => {
|
||||||
|
const userId = req.session.user?.userId
|
||||||
|
if (!userId) throw new Error('Invalid userId.')
|
||||||
|
|
||||||
|
const client = await Client.findOne({ clientId })
|
||||||
|
if (!client) throw new Error('Invalid clientId.')
|
||||||
|
|
||||||
|
// generate authorization code against clientId
|
||||||
|
const userInfo: InfoJWT = {
|
||||||
|
clientId,
|
||||||
|
userId
|
||||||
|
}
|
||||||
|
const code = AuthController.saveCode(
|
||||||
|
userId,
|
||||||
|
clientId,
|
||||||
|
generateAuthCode(userInfo)
|
||||||
|
)
|
||||||
|
|
||||||
|
return { code }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoginPayload {
|
||||||
|
/**
|
||||||
|
* Username for user
|
||||||
|
* @example "secretuser"
|
||||||
|
*/
|
||||||
|
username: string
|
||||||
|
/**
|
||||||
|
* Password for user
|
||||||
|
* @example "secretpassword"
|
||||||
|
*/
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthorizePayload {
|
||||||
|
/**
|
||||||
|
* Client ID
|
||||||
|
* @example "clientID1"
|
||||||
|
*/
|
||||||
|
clientId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthorizeResponse {
|
||||||
|
/**
|
||||||
|
* Authorization code
|
||||||
|
* @example "someRandomCryptoString"
|
||||||
|
*/
|
||||||
|
code: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors = {
|
||||||
|
invalidPassword: {
|
||||||
|
code: 401,
|
||||||
|
message: 'Invalid Password.'
|
||||||
|
},
|
||||||
|
userNotFound: {
|
||||||
|
code: 401,
|
||||||
|
message: 'Username is not found.'
|
||||||
|
},
|
||||||
|
tooManyRequests: (seconds: number) => ({
|
||||||
|
code: 429,
|
||||||
|
message: `Too Many Requests! Retry after ${convertSecondsToHms(seconds)}`
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,42 +1,88 @@
|
|||||||
|
import { RequestHandler, Request, Response, NextFunction } from 'express'
|
||||||
import jwt from 'jsonwebtoken'
|
import jwt from 'jsonwebtoken'
|
||||||
import { verifyTokenInDB } from '../utils'
|
import { csrfProtection } from './'
|
||||||
|
import {
|
||||||
|
fetchLatestAutoExec,
|
||||||
|
ModeType,
|
||||||
|
verifyTokenInDB,
|
||||||
|
isAuthorizingRoute,
|
||||||
|
isPublicRoute,
|
||||||
|
publicUser
|
||||||
|
} from '../utils'
|
||||||
|
import { desktopUser } from './desktop'
|
||||||
|
import { authorize } from './authorize'
|
||||||
|
|
||||||
export const authenticateAccessToken = (req: any, res: any, next: any) => {
|
export const authenticateAccessToken: RequestHandler = async (
|
||||||
authenticateToken(
|
req,
|
||||||
|
res,
|
||||||
|
next
|
||||||
|
) => {
|
||||||
|
const { MODE } = process.env
|
||||||
|
if (MODE === ModeType.Desktop) {
|
||||||
|
req.user = desktopUser
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextFunction = isAuthorizingRoute(req)
|
||||||
|
? () => authorize(req, res, next)
|
||||||
|
: next
|
||||||
|
|
||||||
|
// if request is coming from web and has valid session
|
||||||
|
// it can be validated.
|
||||||
|
if (req.session?.loggedIn) {
|
||||||
|
if (req.session.user) {
|
||||||
|
const user = await fetchLatestAutoExec(req.session.user)
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
if (user.isActive) {
|
||||||
|
req.user = user
|
||||||
|
return csrfProtection(req, res, nextFunction)
|
||||||
|
} else return res.sendStatus(401)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res.sendStatus(401)
|
||||||
|
}
|
||||||
|
|
||||||
|
await authenticateToken(
|
||||||
req,
|
req,
|
||||||
res,
|
res,
|
||||||
next,
|
nextFunction,
|
||||||
process.env.ACCESS_TOKEN_SECRET as string,
|
process.secrets.ACCESS_TOKEN_SECRET,
|
||||||
'accessToken'
|
'accessToken'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const authenticateRefreshToken = (req: any, res: any, next: any) => {
|
export const authenticateRefreshToken: RequestHandler = async (
|
||||||
authenticateToken(
|
req,
|
||||||
|
res,
|
||||||
|
next
|
||||||
|
) => {
|
||||||
|
await authenticateToken(
|
||||||
req,
|
req,
|
||||||
res,
|
res,
|
||||||
next,
|
next,
|
||||||
process.env.REFRESH_TOKEN_SECRET as string,
|
process.secrets.REFRESH_TOKEN_SECRET,
|
||||||
'refreshToken'
|
'refreshToken'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const authenticateToken = (
|
const authenticateToken = async (
|
||||||
req: any,
|
req: Request,
|
||||||
res: any,
|
res: Response,
|
||||||
next: any,
|
next: NextFunction,
|
||||||
key: string,
|
key: string,
|
||||||
tokenType: 'accessToken' | 'refreshToken' = 'accessToken'
|
tokenType: 'accessToken' | 'refreshToken'
|
||||||
) => {
|
) => {
|
||||||
const { MODE } = process.env
|
const { MODE } = process.env
|
||||||
if (MODE?.trim() !== 'server') {
|
if (MODE === ModeType.Desktop) {
|
||||||
req.user = {
|
req.user = {
|
||||||
userId: '1234',
|
userId: 1234,
|
||||||
clientId: 'desktopModeClientId',
|
clientId: 'desktopModeClientId',
|
||||||
username: 'desktopModeUsername',
|
username: 'desktopModeUsername',
|
||||||
displayName: 'desktopModeDisplayName',
|
displayName: 'desktopModeDisplayName',
|
||||||
isAdmin: true,
|
isAdmin: true,
|
||||||
isActive: true
|
isActive: true,
|
||||||
|
needsToUpdatePassword: false
|
||||||
}
|
}
|
||||||
req.accessToken = 'desktopModeAccessToken'
|
req.accessToken = 'desktopModeAccessToken'
|
||||||
return next()
|
return next()
|
||||||
@@ -44,12 +90,12 @@ const authenticateToken = (
|
|||||||
|
|
||||||
const authHeader = req.headers['authorization']
|
const authHeader = req.headers['authorization']
|
||||||
const token = authHeader?.split(' ')[1]
|
const token = authHeader?.split(' ')[1]
|
||||||
if (!token) return res.sendStatus(401)
|
|
||||||
|
|
||||||
jwt.verify(token, key, async (err: any, data: any) => {
|
try {
|
||||||
if (err) return res.sendStatus(401)
|
if (!token) throw 'Unauthorized'
|
||||||
|
|
||||||
|
const data: any = jwt.verify(token, key)
|
||||||
|
|
||||||
// verify this valid token's entry in DB
|
|
||||||
const user = await verifyTokenInDB(
|
const user = await verifyTokenInDB(
|
||||||
data?.userId,
|
data?.userId,
|
||||||
data?.clientId,
|
data?.clientId,
|
||||||
@@ -62,8 +108,16 @@ const authenticateToken = (
|
|||||||
req.user = user
|
req.user = user
|
||||||
if (tokenType === 'accessToken') req.accessToken = token
|
if (tokenType === 'accessToken') req.accessToken = token
|
||||||
return next()
|
return next()
|
||||||
} else return res.sendStatus(401)
|
} else throw 'Unauthorized'
|
||||||
}
|
}
|
||||||
return res.sendStatus(401)
|
|
||||||
})
|
throw 'Unauthorized'
|
||||||
|
} catch (error) {
|
||||||
|
if (await isPublicRoute(req)) {
|
||||||
|
req.user = publicUser
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
|
||||||
|
res.sendStatus(401)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
87
api/src/middlewares/authorize.ts
Normal file
87
api/src/middlewares/authorize.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { RequestHandler } from 'express'
|
||||||
|
import User from '../model/User'
|
||||||
|
import Permission from '../model/Permission'
|
||||||
|
import {
|
||||||
|
PermissionSettingForRoute,
|
||||||
|
PermissionType
|
||||||
|
} from '../controllers/permission'
|
||||||
|
import { getPath, isPublicRoute, TopLevelRoutes } from '../utils'
|
||||||
|
|
||||||
|
export const authorize: RequestHandler = async (req, res, next) => {
|
||||||
|
const { user } = req
|
||||||
|
|
||||||
|
if (!user) return res.sendStatus(401)
|
||||||
|
|
||||||
|
// no need to check for permissions when user is admin
|
||||||
|
if (user.isAdmin) return next()
|
||||||
|
|
||||||
|
// no need to check for permissions when route is Public
|
||||||
|
if (await isPublicRoute(req)) return next()
|
||||||
|
|
||||||
|
const dbUser = await User.findOne({ id: user.userId })
|
||||||
|
if (!dbUser) return res.sendStatus(401)
|
||||||
|
|
||||||
|
const path = getPath(req)
|
||||||
|
const { baseUrl } = req
|
||||||
|
const topLevelRoute =
|
||||||
|
TopLevelRoutes.find((route) => baseUrl.startsWith(route)) || baseUrl
|
||||||
|
|
||||||
|
// find permission w.r.t user
|
||||||
|
const permission = await Permission.findOne({
|
||||||
|
path,
|
||||||
|
type: PermissionType.route,
|
||||||
|
user: dbUser._id
|
||||||
|
})
|
||||||
|
|
||||||
|
if (permission) {
|
||||||
|
if (permission.setting === PermissionSettingForRoute.grant) return next()
|
||||||
|
else return res.sendStatus(401)
|
||||||
|
}
|
||||||
|
|
||||||
|
// find permission w.r.t user on top level
|
||||||
|
const topLevelPermission = await Permission.findOne({
|
||||||
|
path: topLevelRoute,
|
||||||
|
type: PermissionType.route,
|
||||||
|
user: dbUser._id
|
||||||
|
})
|
||||||
|
|
||||||
|
if (topLevelPermission) {
|
||||||
|
if (topLevelPermission.setting === PermissionSettingForRoute.grant)
|
||||||
|
return next()
|
||||||
|
else return res.sendStatus(401)
|
||||||
|
}
|
||||||
|
|
||||||
|
let isPermissionDenied = false
|
||||||
|
|
||||||
|
// find permission w.r.t user's groups
|
||||||
|
for (const group of dbUser.groups) {
|
||||||
|
const groupPermission = await Permission.findOne({
|
||||||
|
path,
|
||||||
|
type: PermissionType.route,
|
||||||
|
group
|
||||||
|
})
|
||||||
|
|
||||||
|
if (groupPermission) {
|
||||||
|
if (groupPermission.setting === PermissionSettingForRoute.grant) {
|
||||||
|
return next()
|
||||||
|
} else {
|
||||||
|
isPermissionDenied = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPermissionDenied) {
|
||||||
|
// find permission w.r.t user's groups on top level
|
||||||
|
for (const group of dbUser.groups) {
|
||||||
|
const groupPermission = await Permission.findOne({
|
||||||
|
path: topLevelRoute,
|
||||||
|
type: PermissionType.route,
|
||||||
|
group
|
||||||
|
})
|
||||||
|
if (groupPermission?.setting === PermissionSettingForRoute.grant)
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.sendStatus(401)
|
||||||
|
}
|
||||||
22
api/src/middlewares/bruteForceProtection.ts
Normal file
22
api/src/middlewares/bruteForceProtection.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { RequestHandler } from 'express'
|
||||||
|
import { convertSecondsToHms } from '@sasjs/utils'
|
||||||
|
import { RateLimiter } from '../utils'
|
||||||
|
|
||||||
|
export const bruteForceProtection: RequestHandler = async (req, res, next) => {
|
||||||
|
const ip = req.ip
|
||||||
|
const username = req.body.username
|
||||||
|
|
||||||
|
const rateLimiter = RateLimiter.getInstance()
|
||||||
|
|
||||||
|
const retrySecs = await rateLimiter.check(ip, username)
|
||||||
|
|
||||||
|
if (retrySecs > 0) {
|
||||||
|
res
|
||||||
|
.status(429)
|
||||||
|
.send(`Too Many Requests! Retry after ${convertSecondsToHms(retrySecs)}`)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next()
|
||||||
|
}
|
||||||
32
api/src/middlewares/csrfProtection.ts
Normal file
32
api/src/middlewares/csrfProtection.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { RequestHandler } from 'express'
|
||||||
|
import csrf from 'csrf'
|
||||||
|
|
||||||
|
const csrfTokens = new csrf()
|
||||||
|
const secret = csrfTokens.secretSync()
|
||||||
|
|
||||||
|
export const generateCSRFToken = () => csrfTokens.create(secret)
|
||||||
|
|
||||||
|
export const csrfProtection: RequestHandler = (req, res, next) => {
|
||||||
|
if (req.method === 'GET') return next()
|
||||||
|
|
||||||
|
// Reads the token from the following locations, in order:
|
||||||
|
// req.body.csrf_token - typically generated by the body-parser module.
|
||||||
|
// req.query.csrf_token - a built-in from Express.js to read from the URL query string.
|
||||||
|
// req.headers['csrf-token'] - the CSRF-Token HTTP request header.
|
||||||
|
// req.headers['xsrf-token'] - the XSRF-Token HTTP request header.
|
||||||
|
// req.headers['x-csrf-token'] - the X-CSRF-Token HTTP request header.
|
||||||
|
// req.headers['x-xsrf-token'] - the X-XSRF-Token HTTP request header.
|
||||||
|
|
||||||
|
const token =
|
||||||
|
req.body?.csrf_token ||
|
||||||
|
req.query?.csrf_token ||
|
||||||
|
req.headers['csrf-token'] ||
|
||||||
|
req.headers['xsrf-token'] ||
|
||||||
|
req.headers['x-csrf-token'] ||
|
||||||
|
req.headers['x-xsrf-token']
|
||||||
|
|
||||||
|
if (!csrfTokens.verify(secret, token)) {
|
||||||
|
return res.status(400).send('Invalid CSRF token!')
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
}
|
||||||
38
api/src/middlewares/desktop.ts
Normal file
38
api/src/middlewares/desktop.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { RequestHandler, Request } from 'express'
|
||||||
|
import { userInfo } from 'os'
|
||||||
|
import { RequestUser } from '../types'
|
||||||
|
import { ModeType } from '../utils'
|
||||||
|
|
||||||
|
const regexUser = /^\/SASjsApi\/user\/[0-9]*$/ // /SASjsApi/user/1
|
||||||
|
|
||||||
|
const allowedInDesktopMode: { [key: string]: RegExp[] } = {
|
||||||
|
GET: [regexUser],
|
||||||
|
PATCH: [regexUser]
|
||||||
|
}
|
||||||
|
|
||||||
|
const reqAllowedInDesktopMode = (request: Request): boolean => {
|
||||||
|
const { method, originalUrl: url } = request
|
||||||
|
|
||||||
|
return !!allowedInDesktopMode[method]?.find((urlRegex) => urlRegex.test(url))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const desktopRestrict: RequestHandler = (req, res, next) => {
|
||||||
|
const { MODE } = process.env
|
||||||
|
|
||||||
|
if (MODE === ModeType.Desktop) {
|
||||||
|
if (!reqAllowedInDesktopMode(req))
|
||||||
|
return res.status(403).send('Not Allowed while in Desktop Mode.')
|
||||||
|
}
|
||||||
|
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const desktopUser: RequestUser = {
|
||||||
|
userId: 12345,
|
||||||
|
clientId: 'desktop_app',
|
||||||
|
username: userInfo().username,
|
||||||
|
displayName: userInfo().username,
|
||||||
|
isAdmin: true,
|
||||||
|
isActive: true,
|
||||||
|
needsToUpdatePassword: false
|
||||||
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
export const desktopRestrict = (req: any, res: any, next: any) => {
|
|
||||||
const { MODE } = process.env
|
|
||||||
if (MODE?.trim() !== 'server')
|
|
||||||
return res.status(403).send('Not Allowed while in Desktop Mode.')
|
|
||||||
|
|
||||||
next()
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
export * from './authenticateToken'
|
export * from './authenticateToken'
|
||||||
export * from './desktopRestrict'
|
export * from './authorize'
|
||||||
|
export * from './csrfProtection'
|
||||||
|
export * from './desktop'
|
||||||
export * from './verifyAdmin'
|
export * from './verifyAdmin'
|
||||||
export * from './verifyAdminIfNeeded'
|
export * from './verifyAdminIfNeeded'
|
||||||
|
export * from './bruteForceProtection'
|
||||||
|
|||||||
72
api/src/middlewares/multer.ts
Normal file
72
api/src/middlewares/multer.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import path from 'path'
|
||||||
|
import { Request } from 'express'
|
||||||
|
import multer, { FileFilterCallback, Options } from 'multer'
|
||||||
|
import { blockFileRegex, getUploadsFolder } from '../utils'
|
||||||
|
|
||||||
|
const fieldNameSize = 300
|
||||||
|
const fileSize = 104857600 // 100 MB
|
||||||
|
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: getUploadsFolder(),
|
||||||
|
filename: function (
|
||||||
|
_req: Request,
|
||||||
|
file: Express.Multer.File,
|
||||||
|
callback: (error: Error | null, filename: string) => void
|
||||||
|
) {
|
||||||
|
callback(
|
||||||
|
null,
|
||||||
|
file.fieldname + path.extname(file.originalname) + '-' + Date.now()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const limits: Options['limits'] = {
|
||||||
|
fieldNameSize,
|
||||||
|
fileSize
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileFilter: Options['fileFilter'] = (
|
||||||
|
req: Request,
|
||||||
|
file: Express.Multer.File,
|
||||||
|
callback: FileFilterCallback
|
||||||
|
) => {
|
||||||
|
const fileExtension = path.extname(file.originalname)
|
||||||
|
const shouldBlockUpload = blockFileRegex.test(file.originalname)
|
||||||
|
if (shouldBlockUpload) {
|
||||||
|
return callback(
|
||||||
|
new Error(`File extension '${fileExtension}' not acceptable.`)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadFileSize = parseInt(req.headers['content-length'] ?? '')
|
||||||
|
if (uploadFileSize > fileSize) {
|
||||||
|
return callback(
|
||||||
|
new Error(
|
||||||
|
`File size is over limit. File limit is: ${fileSize / 1024 / 1024} MB`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
callback(null, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const options: Options = { storage, limits, fileFilter }
|
||||||
|
|
||||||
|
const multerInstance = multer(options)
|
||||||
|
|
||||||
|
export const multerSingle = (fileName: string, arg: any) => {
|
||||||
|
const [req, res, next] = arg
|
||||||
|
const upload = multerInstance.single(fileName)
|
||||||
|
|
||||||
|
upload(req, res, function (err) {
|
||||||
|
if (err instanceof multer.MulterError) {
|
||||||
|
return res.status(500).send(err.message)
|
||||||
|
} else if (err) {
|
||||||
|
return res.status(400).send(err.message)
|
||||||
|
}
|
||||||
|
// Everything went fine.
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default multerInstance
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
export const verifyAdmin = (req: any, res: any, next: any) => {
|
import { RequestHandler } from 'express'
|
||||||
|
import { ModeType } from '../utils'
|
||||||
|
|
||||||
|
export const verifyAdmin: RequestHandler = (req, res, next) => {
|
||||||
const { MODE } = process.env
|
const { MODE } = process.env
|
||||||
if (MODE?.trim() !== 'server') return next()
|
if (MODE === ModeType.Desktop) return next()
|
||||||
|
|
||||||
const { user } = req
|
const { user } = req
|
||||||
if (!user?.isAdmin) return res.status(401).send('Admin account required')
|
if (!user?.isAdmin) return res.status(401).send('Admin account required')
|
||||||
|
|||||||
@@ -1,9 +1,22 @@
|
|||||||
export const verifyAdminIfNeeded = (req: any, res: any, next: any) => {
|
import { RequestHandler } from 'express'
|
||||||
const { user } = req
|
|
||||||
const userId = parseInt(req.params.userId)
|
|
||||||
|
|
||||||
if (!user.isAdmin && user.userId !== userId) {
|
// This middleware checks if a non-admin user trying to
|
||||||
return res.status(401).send('Admin account required')
|
// access information of other user
|
||||||
|
export const verifyAdminIfNeeded: RequestHandler = (req, res, next) => {
|
||||||
|
const { user } = req
|
||||||
|
|
||||||
|
if (!user?.isAdmin) {
|
||||||
|
let adminAccountRequired: boolean = true
|
||||||
|
|
||||||
|
if (req.params.userId) {
|
||||||
|
adminAccountRequired = user?.userId !== parseInt(req.params.userId)
|
||||||
|
} else if (req.params.username) {
|
||||||
|
adminAccountRequired = user?.username !== req.params.username
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adminAccountRequired)
|
||||||
|
return res.status(401).send('Admin account required')
|
||||||
}
|
}
|
||||||
|
|
||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import mongoose, { Schema } from 'mongoose'
|
import mongoose, { Schema } from 'mongoose'
|
||||||
|
|
||||||
|
export const NUMBER_OF_SECONDS_IN_A_DAY = 86400
|
||||||
export interface ClientPayload {
|
export interface ClientPayload {
|
||||||
/**
|
/**
|
||||||
* Client ID
|
* Client ID
|
||||||
@@ -11,6 +12,16 @@ export interface ClientPayload {
|
|||||||
* @example "someRandomCryptoString"
|
* @example "someRandomCryptoString"
|
||||||
*/
|
*/
|
||||||
clientSecret: string
|
clientSecret: string
|
||||||
|
/**
|
||||||
|
* Number of seconds after which access token will expire. Default is 86400 (1 day)
|
||||||
|
* @example 86400
|
||||||
|
*/
|
||||||
|
accessTokenExpiration?: number
|
||||||
|
/**
|
||||||
|
* Number of seconds after which access token will expire. Default is 2592000 (30 days)
|
||||||
|
* @example 2592000
|
||||||
|
*/
|
||||||
|
refreshTokenExpiration?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const ClientSchema = new Schema<ClientPayload>({
|
const ClientSchema = new Schema<ClientPayload>({
|
||||||
@@ -21,6 +32,14 @@ const ClientSchema = new Schema<ClientPayload>({
|
|||||||
clientSecret: {
|
clientSecret: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true
|
required: true
|
||||||
|
},
|
||||||
|
accessTokenExpiration: {
|
||||||
|
type: Number,
|
||||||
|
default: NUMBER_OF_SECONDS_IN_A_DAY
|
||||||
|
},
|
||||||
|
refreshTokenExpiration: {
|
||||||
|
type: Number,
|
||||||
|
default: NUMBER_OF_SECONDS_IN_A_DAY * 30
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
45
api/src/model/Configuration.ts
Normal file
45
api/src/model/Configuration.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import mongoose, { Schema } from 'mongoose'
|
||||||
|
|
||||||
|
export interface ConfigurationType {
|
||||||
|
/**
|
||||||
|
* SecretOrPrivateKey to sign Access Token
|
||||||
|
* @example "someRandomCryptoString"
|
||||||
|
*/
|
||||||
|
ACCESS_TOKEN_SECRET: string
|
||||||
|
/**
|
||||||
|
* SecretOrPrivateKey to sign Refresh Token
|
||||||
|
* @example "someRandomCryptoString"
|
||||||
|
*/
|
||||||
|
REFRESH_TOKEN_SECRET: string
|
||||||
|
/**
|
||||||
|
* SecretOrPrivateKey to sign Auth Code
|
||||||
|
* @example "someRandomCryptoString"
|
||||||
|
*/
|
||||||
|
AUTH_CODE_SECRET: string
|
||||||
|
/**
|
||||||
|
* Secret used to sign the session cookie
|
||||||
|
* @example "someRandomCryptoString"
|
||||||
|
*/
|
||||||
|
SESSION_SECRET: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConfigurationSchema = new Schema<ConfigurationType>({
|
||||||
|
ACCESS_TOKEN_SECRET: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
REFRESH_TOKEN_SECRET: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
AUTH_CODE_SECRET: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
SESSION_SECRET: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default mongoose.model('Configuration', ConfigurationSchema)
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import mongoose, { Schema, model, Document, Model } from 'mongoose'
|
import mongoose, { Schema, model, Document, Model } from 'mongoose'
|
||||||
|
import { GroupDetailsResponse } from '../controllers'
|
||||||
|
import User, { IUser } from './User'
|
||||||
|
import { AuthProviderType } from '../utils'
|
||||||
const AutoIncrement = require('mongoose-sequence')(mongoose)
|
const AutoIncrement = require('mongoose-sequence')(mongoose)
|
||||||
|
|
||||||
|
export const PUBLIC_GROUP_NAME = 'Public'
|
||||||
|
|
||||||
export interface GroupPayload {
|
export interface GroupPayload {
|
||||||
/**
|
/**
|
||||||
* Name of the group
|
* Name of the group
|
||||||
@@ -23,29 +28,37 @@ interface IGroupDocument extends GroupPayload, Document {
|
|||||||
groupId: number
|
groupId: number
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
users: Schema.Types.ObjectId[]
|
users: Schema.Types.ObjectId[]
|
||||||
|
authProvider?: AuthProviderType
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IGroup extends IGroupDocument {
|
interface IGroup extends IGroupDocument {
|
||||||
addUser(userObjectId: Schema.Types.ObjectId): Promise<IGroup>
|
addUser(user: IUser): Promise<GroupDetailsResponse>
|
||||||
removeUser(userObjectId: Schema.Types.ObjectId): Promise<IGroup>
|
removeUser(user: IUser): Promise<GroupDetailsResponse>
|
||||||
|
hasUser(user: IUser): boolean
|
||||||
}
|
}
|
||||||
interface IGroupModel extends Model<IGroup> {}
|
interface IGroupModel extends Model<IGroup> {}
|
||||||
|
|
||||||
const groupSchema = new Schema<IGroupDocument>({
|
const groupSchema = new Schema<IGroupDocument>({
|
||||||
name: {
|
name: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true
|
required: true,
|
||||||
|
unique: true
|
||||||
},
|
},
|
||||||
description: {
|
description: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'Group description.'
|
default: 'Group description.'
|
||||||
},
|
},
|
||||||
|
authProvider: {
|
||||||
|
type: String,
|
||||||
|
enum: AuthProviderType
|
||||||
|
},
|
||||||
isActive: {
|
isActive: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true
|
default: true
|
||||||
},
|
},
|
||||||
users: [{ type: Schema.Types.ObjectId, ref: 'User' }]
|
users: [{ type: Schema.Types.ObjectId, ref: 'User' }]
|
||||||
})
|
})
|
||||||
|
|
||||||
groupSchema.plugin(AutoIncrement, { inc_field: 'groupId' })
|
groupSchema.plugin(AutoIncrement, { inc_field: 'groupId' })
|
||||||
|
|
||||||
// Hooks
|
// Hooks
|
||||||
@@ -55,29 +68,43 @@ groupSchema.post('save', function (group: IGroup, next: Function) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// pre remove hook to remove all references of group from users
|
||||||
|
groupSchema.pre('remove', async function () {
|
||||||
|
const userIds = this.users
|
||||||
|
await Promise.all(
|
||||||
|
userIds.map(async (userId) => {
|
||||||
|
const user = await User.findById(userId)
|
||||||
|
user?.removeGroup(this._id)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
// Instance Methods
|
// Instance Methods
|
||||||
groupSchema.method(
|
groupSchema.method('addUser', async function (user: IUser) {
|
||||||
'addUser',
|
const userObjectId = user._id
|
||||||
async function (userObjectId: Schema.Types.ObjectId) {
|
const userIdIndex = this.users.indexOf(userObjectId)
|
||||||
const userIdIndex = this.users.indexOf(userObjectId)
|
if (userIdIndex === -1) {
|
||||||
if (userIdIndex === -1) {
|
this.users.push(userObjectId)
|
||||||
this.users.push(userObjectId)
|
user.addGroup(this._id)
|
||||||
}
|
|
||||||
this.markModified('users')
|
|
||||||
return this.save()
|
|
||||||
}
|
}
|
||||||
)
|
this.markModified('users')
|
||||||
groupSchema.method(
|
return this.save()
|
||||||
'removeUser',
|
})
|
||||||
async function (userObjectId: Schema.Types.ObjectId) {
|
groupSchema.method('removeUser', async function (user: IUser) {
|
||||||
const userIdIndex = this.users.indexOf(userObjectId)
|
const userObjectId = user._id
|
||||||
if (userIdIndex > -1) {
|
const userIdIndex = this.users.indexOf(userObjectId)
|
||||||
this.users.splice(userIdIndex, 1)
|
if (userIdIndex > -1) {
|
||||||
}
|
this.users.splice(userIdIndex, 1)
|
||||||
this.markModified('users')
|
user.removeGroup(this._id)
|
||||||
return this.save()
|
|
||||||
}
|
}
|
||||||
)
|
this.markModified('users')
|
||||||
|
return this.save()
|
||||||
|
})
|
||||||
|
groupSchema.method('hasUser', function (user: IUser) {
|
||||||
|
const userObjectId = user._id
|
||||||
|
const userIdIndex = this.users.indexOf(userObjectId)
|
||||||
|
return userIdIndex > -1
|
||||||
|
})
|
||||||
|
|
||||||
export const Group: IGroupModel = model<IGroup, IGroupModel>(
|
export const Group: IGroupModel = model<IGroup, IGroupModel>(
|
||||||
'Group',
|
'Group',
|
||||||
|
|||||||
73
api/src/model/Permission.ts
Normal file
73
api/src/model/Permission.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import mongoose, { Schema, model, Document, Model } from 'mongoose'
|
||||||
|
const AutoIncrement = require('mongoose-sequence')(mongoose)
|
||||||
|
import { PermissionDetailsResponse } from '../controllers'
|
||||||
|
|
||||||
|
interface GetPermissionBy {
|
||||||
|
user?: Schema.Types.ObjectId
|
||||||
|
group?: Schema.Types.ObjectId
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IPermissionDocument extends Document {
|
||||||
|
path: string
|
||||||
|
type: string
|
||||||
|
setting: string
|
||||||
|
permissionId: number
|
||||||
|
user: Schema.Types.ObjectId
|
||||||
|
group: Schema.Types.ObjectId
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IPermission extends IPermissionDocument {}
|
||||||
|
|
||||||
|
interface IPermissionModel extends Model<IPermission> {
|
||||||
|
get(getBy: GetPermissionBy): Promise<PermissionDetailsResponse[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissionSchema = new Schema<IPermissionDocument>({
|
||||||
|
path: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
setting: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
user: { type: Schema.Types.ObjectId, ref: 'User' },
|
||||||
|
group: { type: Schema.Types.ObjectId, ref: 'Group' }
|
||||||
|
})
|
||||||
|
|
||||||
|
permissionSchema.plugin(AutoIncrement, { inc_field: 'permissionId' })
|
||||||
|
|
||||||
|
// Static Methods
|
||||||
|
permissionSchema.static('get', async function (getBy: GetPermissionBy): Promise<
|
||||||
|
PermissionDetailsResponse[]
|
||||||
|
> {
|
||||||
|
return (await this.find(getBy)
|
||||||
|
.select({
|
||||||
|
_id: 0,
|
||||||
|
permissionId: 1,
|
||||||
|
path: 1,
|
||||||
|
type: 1,
|
||||||
|
setting: 1
|
||||||
|
})
|
||||||
|
.populate({ path: 'user', select: 'id username displayName isAdmin -_id' })
|
||||||
|
.populate({
|
||||||
|
path: 'group',
|
||||||
|
select: 'groupId name description -_id',
|
||||||
|
populate: {
|
||||||
|
path: 'users',
|
||||||
|
select: 'id username displayName isAdmin -_id',
|
||||||
|
options: { limit: 15 }
|
||||||
|
}
|
||||||
|
})) as unknown as PermissionDetailsResponse[]
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Permission: IPermissionModel = model<
|
||||||
|
IPermission,
|
||||||
|
IPermissionModel
|
||||||
|
>('Permission', permissionSchema)
|
||||||
|
|
||||||
|
export default Permission
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import mongoose, { Schema, model, Document, Model } from 'mongoose'
|
import mongoose, { Schema, model, Document, Model } from 'mongoose'
|
||||||
const AutoIncrement = require('mongoose-sequence')(mongoose)
|
const AutoIncrement = require('mongoose-sequence')(mongoose)
|
||||||
import bcrypt from 'bcryptjs'
|
import bcrypt from 'bcryptjs'
|
||||||
|
import { AuthProviderType } from '../utils'
|
||||||
|
|
||||||
export interface UserPayload {
|
export interface UserPayload {
|
||||||
/**
|
/**
|
||||||
@@ -27,18 +28,29 @@ export interface UserPayload {
|
|||||||
* @example "true"
|
* @example "true"
|
||||||
*/
|
*/
|
||||||
isActive?: boolean
|
isActive?: boolean
|
||||||
|
/**
|
||||||
|
* User-specific auto-exec code
|
||||||
|
* @example ""
|
||||||
|
*/
|
||||||
|
autoExec?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IUserDocument extends UserPayload, Document {
|
interface IUserDocument extends UserPayload, Document {
|
||||||
|
_id: Schema.Types.ObjectId
|
||||||
id: number
|
id: number
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
|
needsToUpdatePassword: boolean
|
||||||
|
autoExec: string
|
||||||
groups: Schema.Types.ObjectId[]
|
groups: Schema.Types.ObjectId[]
|
||||||
tokens: [{ [key: string]: string }]
|
tokens: [{ [key: string]: string }]
|
||||||
|
authProvider?: AuthProviderType
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IUser extends IUserDocument {
|
export interface IUser extends IUserDocument {
|
||||||
comparePassword(password: string): boolean
|
comparePassword(password: string): boolean
|
||||||
|
addGroup(groupObjectId: Schema.Types.ObjectId): Promise<IUser>
|
||||||
|
removeGroup(groupObjectId: Schema.Types.ObjectId): Promise<IUser>
|
||||||
}
|
}
|
||||||
interface IUserModel extends Model<IUser> {
|
interface IUserModel extends Model<IUser> {
|
||||||
hashPassword(password: string): string
|
hashPassword(password: string): string
|
||||||
@@ -58,6 +70,10 @@ const userSchema = new Schema<IUserDocument>({
|
|||||||
type: String,
|
type: String,
|
||||||
required: true
|
required: true
|
||||||
},
|
},
|
||||||
|
authProvider: {
|
||||||
|
type: String,
|
||||||
|
enum: AuthProviderType
|
||||||
|
},
|
||||||
isAdmin: {
|
isAdmin: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
@@ -66,6 +82,13 @@ const userSchema = new Schema<IUserDocument>({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true
|
default: true
|
||||||
},
|
},
|
||||||
|
needsToUpdatePassword: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
autoExec: {
|
||||||
|
type: String
|
||||||
|
},
|
||||||
groups: [{ type: Schema.Types.ObjectId, ref: 'Group' }],
|
groups: [{ type: Schema.Types.ObjectId, ref: 'Group' }],
|
||||||
tokens: [
|
tokens: [
|
||||||
{
|
{
|
||||||
@@ -97,6 +120,28 @@ userSchema.method('comparePassword', function (password: string): boolean {
|
|||||||
if (bcrypt.compareSync(password, this.password)) return true
|
if (bcrypt.compareSync(password, this.password)) return true
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
userSchema.method(
|
||||||
|
'addGroup',
|
||||||
|
async function (groupObjectId: Schema.Types.ObjectId) {
|
||||||
|
const groupIdIndex = this.groups.indexOf(groupObjectId)
|
||||||
|
if (groupIdIndex === -1) {
|
||||||
|
this.groups.push(groupObjectId)
|
||||||
|
}
|
||||||
|
this.markModified('groups')
|
||||||
|
return this.save()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
userSchema.method(
|
||||||
|
'removeGroup',
|
||||||
|
async function (groupObjectId: Schema.Types.ObjectId) {
|
||||||
|
const groupIdIndex = this.groups.indexOf(groupObjectId)
|
||||||
|
if (groupIdIndex > -1) {
|
||||||
|
this.groups.splice(groupIdIndex, 1)
|
||||||
|
}
|
||||||
|
this.markModified('groups')
|
||||||
|
return this.save()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
export const User: IUserModel = model<IUser, IUserModel>('User', userSchema)
|
export const User: IUserModel = model<IUser, IUserModel>('User', userSchema)
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
import path from 'path'
|
|
||||||
import { readFileSync } from 'fs'
|
|
||||||
import * as https from 'https'
|
|
||||||
import appPromise from './app'
|
|
||||||
|
|
||||||
const keyPath = path.join('..', 'certificates', 'privkey.pem')
|
|
||||||
const certPath = path.join('..', 'certificates', 'fullchain.pem')
|
|
||||||
|
|
||||||
const key = readFileSync(keyPath)
|
|
||||||
const cert = readFileSync(certPath)
|
|
||||||
|
|
||||||
appPromise.then((app) => {
|
|
||||||
const httpsServer = https.createServer({ key, cert }, app)
|
|
||||||
|
|
||||||
const sasJsPort = process.env.PORT ?? 5000
|
|
||||||
httpsServer.listen(sasJsPort, () => {
|
|
||||||
console.log(
|
|
||||||
`⚡️[server]: Server is running at https://localhost:${sasJsPort}`
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,58 +1,38 @@
|
|||||||
import express from 'express'
|
import express from 'express'
|
||||||
|
|
||||||
import { AuthController } from '../../controllers/'
|
import { AuthController } from '../../controllers/'
|
||||||
import Client from '../../model/Client'
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
authenticateAccessToken,
|
authenticateAccessToken,
|
||||||
authenticateRefreshToken
|
authenticateRefreshToken
|
||||||
} from '../../middlewares'
|
} from '../../middlewares'
|
||||||
|
|
||||||
import {
|
import { tokenValidation, updatePasswordValidation } from '../../utils'
|
||||||
authorizeValidation,
|
|
||||||
getDesktopFields,
|
|
||||||
tokenValidation
|
|
||||||
} from '../../utils'
|
|
||||||
import { InfoJWT } from '../../types'
|
import { InfoJWT } from '../../types'
|
||||||
|
|
||||||
const authRouter = express.Router()
|
const authRouter = express.Router()
|
||||||
|
const controller = new AuthController()
|
||||||
|
|
||||||
const clientIDs = new Set()
|
authRouter.patch(
|
||||||
|
'/updatePassword',
|
||||||
|
authenticateAccessToken,
|
||||||
|
async (req, res) => {
|
||||||
|
const { error, value: body } = updatePasswordValidation(req.body)
|
||||||
|
if (error) return res.status(400).send(error.details[0].message)
|
||||||
|
|
||||||
export const populateClients = async () => {
|
try {
|
||||||
const result = await Client.find()
|
await controller.updatePassword(req, body)
|
||||||
clientIDs.clear()
|
res.sendStatus(204)
|
||||||
result.forEach((r) => {
|
} catch (err: any) {
|
||||||
clientIDs.add(r.clientId)
|
res.status(err.code).send(err.message)
|
||||||
})
|
}
|
||||||
}
|
|
||||||
|
|
||||||
authRouter.post('/authorize', async (req, res) => {
|
|
||||||
const { error, value: body } = authorizeValidation(req.body)
|
|
||||||
if (error) return res.status(400).send(error.details[0].message)
|
|
||||||
|
|
||||||
const { clientId } = body
|
|
||||||
|
|
||||||
// Verify client ID
|
|
||||||
if (!clientIDs.has(clientId)) {
|
|
||||||
return res.status(403).send('Invalid clientId.')
|
|
||||||
}
|
}
|
||||||
|
)
|
||||||
const controller = new AuthController()
|
|
||||||
try {
|
|
||||||
const response = await controller.authorize(body)
|
|
||||||
|
|
||||||
res.send(response)
|
|
||||||
} catch (err: any) {
|
|
||||||
res.status(403).send(err.toString())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
authRouter.post('/token', async (req, res) => {
|
authRouter.post('/token', async (req, res) => {
|
||||||
const { error, value: body } = tokenValidation(req.body)
|
const { error, value: body } = tokenValidation(req.body)
|
||||||
if (error) return res.status(400).send(error.details[0].message)
|
if (error) return res.status(400).send(error.details[0].message)
|
||||||
|
|
||||||
const controller = new AuthController()
|
|
||||||
try {
|
try {
|
||||||
const response = await controller.token(body)
|
const response = await controller.token(body)
|
||||||
|
|
||||||
@@ -62,10 +42,12 @@ authRouter.post('/token', async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
authRouter.post('/refresh', authenticateRefreshToken, async (req: any, res) => {
|
authRouter.post('/refresh', authenticateRefreshToken, async (req, res) => {
|
||||||
const userInfo: InfoJWT = req.user
|
const userInfo: InfoJWT = {
|
||||||
|
userId: req.user!.userId!,
|
||||||
|
clientId: req.user!.clientId!
|
||||||
|
}
|
||||||
|
|
||||||
const controller = new AuthController()
|
|
||||||
try {
|
try {
|
||||||
const response = await controller.refresh(userInfo)
|
const response = await controller.refresh(userInfo)
|
||||||
|
|
||||||
@@ -75,10 +57,12 @@ authRouter.post('/refresh', authenticateRefreshToken, async (req: any, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
authRouter.delete('/logout', authenticateAccessToken, async (req: any, res) => {
|
authRouter.delete('/logout', authenticateAccessToken, async (req, res) => {
|
||||||
const userInfo: InfoJWT = req.user
|
const userInfo: InfoJWT = {
|
||||||
|
userId: req.user!.userId!,
|
||||||
|
clientId: req.user!.clientId!
|
||||||
|
}
|
||||||
|
|
||||||
const controller = new AuthController()
|
|
||||||
try {
|
try {
|
||||||
await controller.logout(userInfo)
|
await controller.logout(userInfo)
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|||||||
25
api/src/routes/api/authConfig.ts
Normal file
25
api/src/routes/api/authConfig.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import express from 'express'
|
||||||
|
import { AuthConfigController } from '../../controllers'
|
||||||
|
const authConfigRouter = express.Router()
|
||||||
|
|
||||||
|
authConfigRouter.get('/', async (req, res) => {
|
||||||
|
const controller = new AuthConfigController()
|
||||||
|
try {
|
||||||
|
const response = controller.getDetail()
|
||||||
|
res.send(response)
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).send(err.toString())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
authConfigRouter.post('/synchroniseWithLDAP', async (req, res) => {
|
||||||
|
const controller = new AuthConfigController()
|
||||||
|
try {
|
||||||
|
const response = await controller.synchroniseWithLDAP()
|
||||||
|
res.send(response)
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).send(err.toString())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default authConfigRouter
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import express from 'express'
|
import express from 'express'
|
||||||
import { ClientController } from '../../controllers'
|
import { ClientController } from '../../controllers'
|
||||||
import { registerClientValidation } from '../../utils'
|
import { registerClientValidation } from '../../utils'
|
||||||
|
import { authenticateAccessToken, verifyAdmin } from '../../middlewares'
|
||||||
|
|
||||||
const clientRouter = express.Router()
|
const clientRouter = express.Router()
|
||||||
|
|
||||||
@@ -17,4 +18,19 @@ clientRouter.post('/', async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
clientRouter.get(
|
||||||
|
'/',
|
||||||
|
authenticateAccessToken,
|
||||||
|
verifyAdmin,
|
||||||
|
async (req, res) => {
|
||||||
|
const controller = new ClientController()
|
||||||
|
try {
|
||||||
|
const response = await controller.getAllClients()
|
||||||
|
res.send(response)
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(403).send(err.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
export default clientRouter
|
export default clientRouter
|
||||||
|
|||||||
31
api/src/routes/api/code.ts
Normal file
31
api/src/routes/api/code.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import express from 'express'
|
||||||
|
import { runCodeValidation } from '../../utils'
|
||||||
|
import { CodeController } from '../../controllers/'
|
||||||
|
|
||||||
|
const runRouter = express.Router()
|
||||||
|
|
||||||
|
const controller = new CodeController()
|
||||||
|
|
||||||
|
runRouter.post('/execute', async (req, res) => {
|
||||||
|
const { error, value: body } = runCodeValidation(req.body)
|
||||||
|
if (error) return res.status(400).send(error.details[0].message)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await controller.executeCode(req, body)
|
||||||
|
|
||||||
|
if (response instanceof Buffer) {
|
||||||
|
res.writeHead(200, (req as any).sasHeaders)
|
||||||
|
return res.end(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
res.send(response)
|
||||||
|
} catch (err: any) {
|
||||||
|
const statusCode = err.code
|
||||||
|
|
||||||
|
delete err.code
|
||||||
|
|
||||||
|
res.status(statusCode).send(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default runRouter
|
||||||
@@ -1,13 +1,43 @@
|
|||||||
import express from 'express'
|
import express from 'express'
|
||||||
|
import { deleteFile, readFile } from '@sasjs/utils'
|
||||||
|
|
||||||
|
import { publishAppStream } from '../appStream'
|
||||||
|
|
||||||
|
import { multerSingle } from '../../middlewares/multer'
|
||||||
import { DriveController } from '../../controllers/'
|
import { DriveController } from '../../controllers/'
|
||||||
import { getFileDriveValidation, updateFileDriveValidation } from '../../utils'
|
import {
|
||||||
|
deployValidation,
|
||||||
|
extractJSONFromZip,
|
||||||
|
extractName,
|
||||||
|
fileBodyValidation,
|
||||||
|
fileParamValidation,
|
||||||
|
folderBodyValidation,
|
||||||
|
folderParamValidation,
|
||||||
|
isZipFile,
|
||||||
|
renameBodyValidation
|
||||||
|
} from '../../utils'
|
||||||
|
|
||||||
|
const controller = new DriveController()
|
||||||
|
|
||||||
const driveRouter = express.Router()
|
const driveRouter = express.Router()
|
||||||
|
|
||||||
driveRouter.post('/deploy', async (req, res) => {
|
driveRouter.post('/deploy', async (req, res) => {
|
||||||
const controller = new DriveController()
|
const { error, value: body } = deployValidation(req.body)
|
||||||
|
if (error) return res.status(400).send(error.details[0].message)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await controller.deploy(req.body)
|
const response = await controller.deploy(body)
|
||||||
|
|
||||||
|
if (body.streamWebFolder) {
|
||||||
|
const { streamServiceName } = await publishAppStream(
|
||||||
|
body.appLoc,
|
||||||
|
body.streamWebFolder,
|
||||||
|
body.streamServiceName,
|
||||||
|
body.streamLogo
|
||||||
|
)
|
||||||
|
response.streamServiceName = streamServiceName
|
||||||
|
}
|
||||||
|
|
||||||
res.send(response)
|
res.send(response)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const statusCode = err.code
|
const statusCode = err.code
|
||||||
@@ -18,59 +48,239 @@ driveRouter.post('/deploy', async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
driveRouter.post(
|
||||||
|
'/deploy/upload',
|
||||||
|
(...arg) => multerSingle('file', arg),
|
||||||
|
async (req, res) => {
|
||||||
|
if (!req.file) return res.status(400).send('"file" is not present.')
|
||||||
|
|
||||||
|
let fileContent: string = ''
|
||||||
|
|
||||||
|
const { value: zipFile } = isZipFile(req.file)
|
||||||
|
if (zipFile) {
|
||||||
|
fileContent = await extractJSONFromZip(zipFile)
|
||||||
|
const fileInZip = extractName(zipFile.originalname)
|
||||||
|
|
||||||
|
if (!fileContent) {
|
||||||
|
deleteFile(req.file.path)
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.send(
|
||||||
|
`No content present in ${fileInZip} of compressed file ${zipFile.originalname}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fileContent = await readFile(req.file.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
let jsonContent
|
||||||
|
try {
|
||||||
|
jsonContent = JSON.parse(fileContent)
|
||||||
|
} catch (err) {
|
||||||
|
deleteFile(req.file.path)
|
||||||
|
return res.status(400).send('File containing invalid JSON content.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error, value: body } = deployValidation(jsonContent)
|
||||||
|
if (error) {
|
||||||
|
deleteFile(req.file.path)
|
||||||
|
return res.status(400).send(error.details[0].message)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await controller.deployUpload(req.file, body)
|
||||||
|
|
||||||
|
if (body.streamWebFolder) {
|
||||||
|
const { streamServiceName } = await publishAppStream(
|
||||||
|
body.appLoc,
|
||||||
|
body.streamWebFolder,
|
||||||
|
body.streamServiceName,
|
||||||
|
body.streamLogo
|
||||||
|
)
|
||||||
|
response.streamServiceName = streamServiceName
|
||||||
|
}
|
||||||
|
|
||||||
|
res.send(response)
|
||||||
|
} catch (err: any) {
|
||||||
|
const statusCode = err.code
|
||||||
|
|
||||||
|
delete err.code
|
||||||
|
|
||||||
|
res.status(statusCode).send(err)
|
||||||
|
} finally {
|
||||||
|
deleteFile(req.file.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
driveRouter.get('/file', async (req, res) => {
|
driveRouter.get('/file', async (req, res) => {
|
||||||
const { error, value: query } = getFileDriveValidation(req.query)
|
const { error: errQ, value: query } = fileParamValidation(req.query)
|
||||||
if (error) return res.status(400).send(error.details[0].message)
|
|
||||||
|
if (errQ) return res.status(400).send(errQ.details[0].message)
|
||||||
|
|
||||||
const controller = new DriveController()
|
|
||||||
try {
|
try {
|
||||||
const response = await controller.getFile(query.filePath)
|
await controller.getFile(req, query._filePath)
|
||||||
res.send(response)
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const statusCode = err.code
|
const statusCode = err.code
|
||||||
|
|
||||||
delete err.code
|
delete err.code
|
||||||
|
|
||||||
res.status(statusCode).send(err)
|
res.status(statusCode).send(err.message)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
driveRouter.post('/file', async (req, res) => {
|
driveRouter.get('/folder', async (req, res) => {
|
||||||
const { error, value: body } = updateFileDriveValidation(req.body)
|
const { error: errQ, value: query } = folderParamValidation(req.query)
|
||||||
if (error) return res.status(400).send(error.details[0].message)
|
|
||||||
|
if (errQ) return res.status(400).send(errQ.details[0].message)
|
||||||
|
|
||||||
const controller = new DriveController()
|
|
||||||
try {
|
try {
|
||||||
const response = await controller.saveFile(body)
|
const response = await controller.getFolder(query._folderPath)
|
||||||
res.send(response)
|
res.send(response)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const statusCode = err.code
|
const statusCode = err.code
|
||||||
|
|
||||||
delete err.code
|
delete err.code
|
||||||
|
|
||||||
res.status(statusCode).send(err)
|
res.status(statusCode).send(err.message)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
driveRouter.patch('/file', async (req, res) => {
|
driveRouter.delete('/file', async (req, res) => {
|
||||||
const { error, value: body } = updateFileDriveValidation(req.body)
|
const { error: errQ, value: query } = fileParamValidation(req.query)
|
||||||
if (error) return res.status(400).send(error.details[0].message)
|
|
||||||
|
if (errQ) return res.status(400).send(errQ.details[0].message)
|
||||||
|
|
||||||
const controller = new DriveController()
|
|
||||||
try {
|
try {
|
||||||
const response = await controller.updateFile(body)
|
const response = await controller.deleteFile(query._filePath)
|
||||||
res.send(response)
|
res.send(response)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const statusCode = err.code
|
const statusCode = err.code
|
||||||
|
|
||||||
delete err.code
|
delete err.code
|
||||||
|
|
||||||
res.status(statusCode).send(err)
|
res.status(statusCode).send(err.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
driveRouter.delete('/folder', async (req, res) => {
|
||||||
|
const { error: errQ, value: query } = folderParamValidation(req.query, true)
|
||||||
|
|
||||||
|
if (errQ) return res.status(400).send(errQ.details[0].message)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await controller.deleteFolder(query._folderPath)
|
||||||
|
res.send(response)
|
||||||
|
} catch (err: any) {
|
||||||
|
const statusCode = err.code
|
||||||
|
|
||||||
|
delete err.code
|
||||||
|
|
||||||
|
res.status(statusCode).send(err.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
driveRouter.post(
|
||||||
|
'/file',
|
||||||
|
(...arg) => multerSingle('file', arg),
|
||||||
|
async (req, res) => {
|
||||||
|
const { error: errQ, value: query } = fileParamValidation(req.query)
|
||||||
|
const { error: errB, value: body } = fileBodyValidation(req.body)
|
||||||
|
|
||||||
|
if (errQ && errB) {
|
||||||
|
if (req.file) await deleteFile(req.file.path)
|
||||||
|
return res.status(400).send(errQ.details[0].message)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.file) return res.status(400).send('"file" is not present.')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await controller.saveFile(
|
||||||
|
req.file,
|
||||||
|
query._filePath,
|
||||||
|
body.filePath
|
||||||
|
)
|
||||||
|
res.send(response)
|
||||||
|
} catch (err: any) {
|
||||||
|
await deleteFile(req.file.path)
|
||||||
|
|
||||||
|
const statusCode = err.code
|
||||||
|
|
||||||
|
delete err.code
|
||||||
|
|
||||||
|
res.status(statusCode).send(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
driveRouter.post('/folder', async (req, res) => {
|
||||||
|
const { error, value: body } = folderBodyValidation(req.body)
|
||||||
|
|
||||||
|
if (error) return res.status(400).send(error.details[0].message)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await controller.addFolder(body)
|
||||||
|
res.send(response)
|
||||||
|
} catch (err: any) {
|
||||||
|
const statusCode = err.code
|
||||||
|
|
||||||
|
delete err.code
|
||||||
|
|
||||||
|
res.status(statusCode).send(err.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
driveRouter.patch(
|
||||||
|
'/file',
|
||||||
|
(...arg) => multerSingle('file', arg),
|
||||||
|
async (req, res) => {
|
||||||
|
const { error: errQ, value: query } = fileParamValidation(req.query)
|
||||||
|
const { error: errB, value: body } = fileBodyValidation(req.body)
|
||||||
|
|
||||||
|
if (errQ && errB) {
|
||||||
|
if (req.file) await deleteFile(req.file.path)
|
||||||
|
return res.status(400).send(errQ.details[0].message)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.file) return res.status(400).send('"file" is not present.')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await controller.updateFile(
|
||||||
|
req.file,
|
||||||
|
query._filePath,
|
||||||
|
body.filePath
|
||||||
|
)
|
||||||
|
res.send(response)
|
||||||
|
} catch (err: any) {
|
||||||
|
await deleteFile(req.file.path)
|
||||||
|
|
||||||
|
const statusCode = err.code
|
||||||
|
|
||||||
|
delete err.code
|
||||||
|
|
||||||
|
res.status(statusCode).send(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
driveRouter.post('/rename', async (req, res) => {
|
||||||
|
const { error, value: body } = renameBodyValidation(req.body)
|
||||||
|
|
||||||
|
if (error) return res.status(400).send(error.details[0].message)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await controller.rename(body)
|
||||||
|
res.send(response)
|
||||||
|
} catch (err: any) {
|
||||||
|
const statusCode = err.code
|
||||||
|
|
||||||
|
delete err.code
|
||||||
|
|
||||||
|
res.status(statusCode).send(err.message)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
driveRouter.get('/fileTree', async (req, res) => {
|
driveRouter.get('/fileTree', async (req, res) => {
|
||||||
const controller = new DriveController()
|
|
||||||
try {
|
try {
|
||||||
const response = await controller.getFileTree()
|
const response = await controller.getFileTree()
|
||||||
res.send(response)
|
res.send(response)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import express from 'express'
|
import express from 'express'
|
||||||
import { GroupController } from '../../controllers/'
|
import { GroupController } from '../../controllers/'
|
||||||
import { authenticateAccessToken, verifyAdmin } from '../../middlewares'
|
import { authenticateAccessToken, verifyAdmin } from '../../middlewares'
|
||||||
import { registerGroupValidation } from '../../utils'
|
import { getGroupValidation, registerGroupValidation } from '../../utils'
|
||||||
|
|
||||||
const groupRouter = express.Router()
|
const groupRouter = express.Router()
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ groupRouter.post(
|
|||||||
const response = await controller.createGroup(body)
|
const response = await controller.createGroup(body)
|
||||||
res.send(response)
|
res.send(response)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(403).send(err.toString())
|
res.status(err.code).send(err.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -29,35 +29,57 @@ groupRouter.get('/', authenticateAccessToken, async (req, res) => {
|
|||||||
const response = await controller.getAllGroups()
|
const response = await controller.getAllGroups()
|
||||||
res.send(response)
|
res.send(response)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(403).send(err.toString())
|
res.status(err.code).send(err.message)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
groupRouter.get('/:groupId', authenticateAccessToken, async (req: any, res) => {
|
groupRouter.get('/:groupId', authenticateAccessToken, async (req, res) => {
|
||||||
const { groupId } = req.params
|
const { groupId } = req.params
|
||||||
|
|
||||||
const controller = new GroupController()
|
const controller = new GroupController()
|
||||||
try {
|
try {
|
||||||
const response = await controller.getGroup(groupId)
|
const response = await controller.getGroup(parseInt(groupId))
|
||||||
res.send(response)
|
res.send(response)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(403).send(err.toString())
|
res.status(err.code).send(err.message)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
groupRouter.get(
|
||||||
|
'/by/groupname/:name',
|
||||||
|
authenticateAccessToken,
|
||||||
|
async (req, res) => {
|
||||||
|
const { error, value: params } = getGroupValidation(req.params)
|
||||||
|
if (error) return res.status(400).send(error.details[0].message)
|
||||||
|
|
||||||
|
const { name } = params
|
||||||
|
|
||||||
|
const controller = new GroupController()
|
||||||
|
try {
|
||||||
|
const response = await controller.getGroupByGroupName(name)
|
||||||
|
res.send(response)
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(err.code).send(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
groupRouter.post(
|
groupRouter.post(
|
||||||
'/:groupId/:userId',
|
'/:groupId/:userId',
|
||||||
authenticateAccessToken,
|
authenticateAccessToken,
|
||||||
verifyAdmin,
|
verifyAdmin,
|
||||||
async (req: any, res) => {
|
async (req, res) => {
|
||||||
const { groupId, userId } = req.params
|
const { groupId, userId } = req.params
|
||||||
|
|
||||||
const controller = new GroupController()
|
const controller = new GroupController()
|
||||||
try {
|
try {
|
||||||
const response = await controller.addUserToGroup(groupId, userId)
|
const response = await controller.addUserToGroup(
|
||||||
|
parseInt(groupId),
|
||||||
|
parseInt(userId)
|
||||||
|
)
|
||||||
res.send(response)
|
res.send(response)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(403).send(err.toString())
|
res.status(err.code).send(err.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -66,15 +88,18 @@ groupRouter.delete(
|
|||||||
'/:groupId/:userId',
|
'/:groupId/:userId',
|
||||||
authenticateAccessToken,
|
authenticateAccessToken,
|
||||||
verifyAdmin,
|
verifyAdmin,
|
||||||
async (req: any, res) => {
|
async (req, res) => {
|
||||||
const { groupId, userId } = req.params
|
const { groupId, userId } = req.params
|
||||||
|
|
||||||
const controller = new GroupController()
|
const controller = new GroupController()
|
||||||
try {
|
try {
|
||||||
const response = await controller.removeUserFromGroup(groupId, userId)
|
const response = await controller.removeUserFromGroup(
|
||||||
|
parseInt(groupId),
|
||||||
|
parseInt(userId)
|
||||||
|
)
|
||||||
res.send(response)
|
res.send(response)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(403).send(err.toString())
|
res.status(err.code).send(err.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -83,15 +108,15 @@ groupRouter.delete(
|
|||||||
'/:groupId',
|
'/:groupId',
|
||||||
authenticateAccessToken,
|
authenticateAccessToken,
|
||||||
verifyAdmin,
|
verifyAdmin,
|
||||||
async (req: any, res) => {
|
async (req, res) => {
|
||||||
const { groupId } = req.params
|
const { groupId } = req.params
|
||||||
|
|
||||||
const controller = new GroupController()
|
const controller = new GroupController()
|
||||||
try {
|
try {
|
||||||
await controller.deleteGroup(groupId)
|
await controller.deleteGroup(parseInt(groupId))
|
||||||
res.status(200).send('Group Deleted!')
|
res.status(200).send('Group Deleted!')
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(403).send(err.toString())
|
res.status(err.code).send(err.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,15 +8,22 @@ import {
|
|||||||
verifyAdmin
|
verifyAdmin
|
||||||
} from '../../middlewares'
|
} from '../../middlewares'
|
||||||
|
|
||||||
|
import infoRouter from './info'
|
||||||
import driveRouter from './drive'
|
import driveRouter from './drive'
|
||||||
import stpRouter from './stp'
|
import stpRouter from './stp'
|
||||||
|
import codeRouter from './code'
|
||||||
import userRouter from './user'
|
import userRouter from './user'
|
||||||
import groupRouter from './group'
|
import groupRouter from './group'
|
||||||
import clientRouter from './client'
|
import clientRouter from './client'
|
||||||
import authRouter from './auth'
|
import authRouter from './auth'
|
||||||
|
import sessionRouter from './session'
|
||||||
|
import permissionRouter from './permission'
|
||||||
|
import authConfigRouter from './authConfig'
|
||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
|
router.use('/info', infoRouter)
|
||||||
|
router.use('/session', authenticateAccessToken, sessionRouter)
|
||||||
router.use('/auth', desktopRestrict, authRouter)
|
router.use('/auth', desktopRestrict, authRouter)
|
||||||
router.use(
|
router.use(
|
||||||
'/client',
|
'/client',
|
||||||
@@ -28,13 +35,38 @@ router.use(
|
|||||||
router.use('/drive', authenticateAccessToken, driveRouter)
|
router.use('/drive', authenticateAccessToken, driveRouter)
|
||||||
router.use('/group', desktopRestrict, groupRouter)
|
router.use('/group', desktopRestrict, groupRouter)
|
||||||
router.use('/stp', authenticateAccessToken, stpRouter)
|
router.use('/stp', authenticateAccessToken, stpRouter)
|
||||||
|
router.use('/code', authenticateAccessToken, codeRouter)
|
||||||
router.use('/user', desktopRestrict, userRouter)
|
router.use('/user', desktopRestrict, userRouter)
|
||||||
|
router.use(
|
||||||
|
'/permission',
|
||||||
|
desktopRestrict,
|
||||||
|
authenticateAccessToken,
|
||||||
|
permissionRouter
|
||||||
|
)
|
||||||
|
|
||||||
|
router.use(
|
||||||
|
'/authConfig',
|
||||||
|
desktopRestrict,
|
||||||
|
authenticateAccessToken,
|
||||||
|
verifyAdmin,
|
||||||
|
authConfigRouter
|
||||||
|
)
|
||||||
|
|
||||||
router.use(
|
router.use(
|
||||||
'/',
|
'/',
|
||||||
swaggerUi.serve,
|
swaggerUi.serve,
|
||||||
swaggerUi.setup(undefined, {
|
swaggerUi.setup(undefined, {
|
||||||
swaggerOptions: {
|
swaggerOptions: {
|
||||||
url: '/swagger.yaml'
|
url: '/swagger.yaml',
|
||||||
|
requestInterceptor: (request: any) => {
|
||||||
|
request.credentials = 'include'
|
||||||
|
|
||||||
|
const cookie = document.cookie
|
||||||
|
const startIndex = cookie.indexOf('XSRF-TOKEN')
|
||||||
|
const csrf = cookie.slice(startIndex + 11).split('; ')[0]
|
||||||
|
request.headers['X-XSRF-TOKEN'] = csrf
|
||||||
|
return request
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|||||||
26
api/src/routes/api/info.ts
Normal file
26
api/src/routes/api/info.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import express from 'express'
|
||||||
|
import { InfoController } from '../../controllers'
|
||||||
|
|
||||||
|
const infoRouter = express.Router()
|
||||||
|
|
||||||
|
infoRouter.get('/', async (req, res) => {
|
||||||
|
const controller = new InfoController()
|
||||||
|
try {
|
||||||
|
const response = controller.info()
|
||||||
|
res.send(response)
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(403).send(err.toString())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
infoRouter.get('/authorizedRoutes', async (req, res) => {
|
||||||
|
const controller = new InfoController()
|
||||||
|
try {
|
||||||
|
const response = controller.authorizedRoutes()
|
||||||
|
res.send(response)
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(403).send(err.toString())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default infoRouter
|
||||||
69
api/src/routes/api/permission.ts
Normal file
69
api/src/routes/api/permission.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import express from 'express'
|
||||||
|
import { PermissionController } from '../../controllers/'
|
||||||
|
import { verifyAdmin } from '../../middlewares'
|
||||||
|
import {
|
||||||
|
registerPermissionValidation,
|
||||||
|
updatePermissionValidation
|
||||||
|
} from '../../utils'
|
||||||
|
|
||||||
|
const permissionRouter = express.Router()
|
||||||
|
const controller = new PermissionController()
|
||||||
|
|
||||||
|
permissionRouter.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await controller.getAllPermissions(req)
|
||||||
|
res.send(response)
|
||||||
|
} catch (err: any) {
|
||||||
|
const statusCode = err.code
|
||||||
|
delete err.code
|
||||||
|
res.status(statusCode).send(err.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
permissionRouter.post('/', verifyAdmin, async (req, res) => {
|
||||||
|
const { error, value: body } = registerPermissionValidation(req.body)
|
||||||
|
if (error) return res.status(400).send(error.details[0].message)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await controller.createPermission(body)
|
||||||
|
res.send(response)
|
||||||
|
} catch (err: any) {
|
||||||
|
const statusCode = err.code
|
||||||
|
delete err.code
|
||||||
|
res.status(statusCode).send(err.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
permissionRouter.patch('/:permissionId', verifyAdmin, async (req: any, res) => {
|
||||||
|
const { permissionId } = req.params
|
||||||
|
|
||||||
|
const { error, value: body } = updatePermissionValidation(req.body)
|
||||||
|
if (error) return res.status(400).send(error.details[0].message)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await controller.updatePermission(permissionId, body)
|
||||||
|
res.send(response)
|
||||||
|
} catch (err: any) {
|
||||||
|
const statusCode = err.code
|
||||||
|
delete err.code
|
||||||
|
res.status(statusCode).send(err.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
permissionRouter.delete(
|
||||||
|
'/:permissionId',
|
||||||
|
verifyAdmin,
|
||||||
|
async (req: any, res) => {
|
||||||
|
const { permissionId } = req.params
|
||||||
|
|
||||||
|
try {
|
||||||
|
await controller.deletePermission(permissionId)
|
||||||
|
res.status(200).send('Permission Deleted!')
|
||||||
|
} catch (err: any) {
|
||||||
|
const statusCode = err.code
|
||||||
|
delete err.code
|
||||||
|
res.status(statusCode).send(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
export default permissionRouter
|
||||||
16
api/src/routes/api/session.ts
Normal file
16
api/src/routes/api/session.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import express from 'express'
|
||||||
|
import { SessionController } from '../../controllers'
|
||||||
|
|
||||||
|
const sessionRouter = express.Router()
|
||||||
|
|
||||||
|
sessionRouter.get('/', async (req, res) => {
|
||||||
|
const controller = new SessionController()
|
||||||
|
try {
|
||||||
|
const response = await controller.session(req)
|
||||||
|
res.send(response)
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(403).send(err.toString())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default sessionRouter
|
||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
ClientController,
|
ClientController,
|
||||||
AuthController
|
AuthController
|
||||||
} from '../../../controllers/'
|
} from '../../../controllers/'
|
||||||
import { populateClients } from '../auth'
|
|
||||||
import { InfoJWT } from '../../../types'
|
import { InfoJWT } from '../../../types'
|
||||||
import {
|
import {
|
||||||
generateAccessToken,
|
generateAccessToken,
|
||||||
@@ -18,11 +17,6 @@ import {
|
|||||||
verifyTokenInDB
|
verifyTokenInDB
|
||||||
} from '../../../utils'
|
} from '../../../utils'
|
||||||
|
|
||||||
let app: Express
|
|
||||||
appPromise.then((_app) => {
|
|
||||||
app = _app
|
|
||||||
})
|
|
||||||
|
|
||||||
const clientId = 'someclientID'
|
const clientId = 'someclientID'
|
||||||
const clientSecret = 'someclientSecret'
|
const clientSecret = 'someclientSecret'
|
||||||
const user = {
|
const user = {
|
||||||
@@ -35,16 +29,18 @@ const user = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('auth', () => {
|
describe('auth', () => {
|
||||||
|
let app: Express
|
||||||
let con: Mongoose
|
let con: Mongoose
|
||||||
let mongoServer: MongoMemoryServer
|
let mongoServer: MongoMemoryServer
|
||||||
const userController = new UserController()
|
const userController = new UserController()
|
||||||
const clientController = new ClientController()
|
const clientController = new ClientController()
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
app = await appPromise
|
||||||
|
|
||||||
mongoServer = await MongoMemoryServer.create()
|
mongoServer = await MongoMemoryServer.create()
|
||||||
con = await mongoose.connect(mongoServer.getUri())
|
con = await mongoose.connect(mongoServer.getUri())
|
||||||
await clientController.createClient({ clientId, clientSecret })
|
await clientController.createClient({ clientId, clientSecret })
|
||||||
await populateClients()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -53,114 +49,6 @@ describe('auth', () => {
|
|||||||
await mongoServer.stop()
|
await mongoServer.stop()
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('authorize', () => {
|
|
||||||
afterEach(async () => {
|
|
||||||
const collections = mongoose.connection.collections
|
|
||||||
const collection = collections['users']
|
|
||||||
await collection.deleteMany({})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should respond with authorization code', async () => {
|
|
||||||
await userController.createUser(user)
|
|
||||||
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/SASjsApi/auth/authorize')
|
|
||||||
.send({
|
|
||||||
username: user.username,
|
|
||||||
password: user.password,
|
|
||||||
clientId
|
|
||||||
})
|
|
||||||
.expect(200)
|
|
||||||
|
|
||||||
expect(res.body).toHaveProperty('code')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should respond with Bad Request if username is missing', async () => {
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/SASjsApi/auth/authorize')
|
|
||||||
.send({
|
|
||||||
password: user.password,
|
|
||||||
clientId
|
|
||||||
})
|
|
||||||
.expect(400)
|
|
||||||
|
|
||||||
expect(res.text).toEqual(`"username" is required`)
|
|
||||||
expect(res.body).toEqual({})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should respond with Bad Request if password is missing', async () => {
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/SASjsApi/auth/authorize')
|
|
||||||
.send({
|
|
||||||
username: user.username,
|
|
||||||
clientId
|
|
||||||
})
|
|
||||||
.expect(400)
|
|
||||||
|
|
||||||
expect(res.text).toEqual(`"password" is required`)
|
|
||||||
expect(res.body).toEqual({})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should respond with Bad Request if clientId is missing', async () => {
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/SASjsApi/auth/authorize')
|
|
||||||
.send({
|
|
||||||
username: user.username,
|
|
||||||
password: user.password
|
|
||||||
})
|
|
||||||
.expect(400)
|
|
||||||
|
|
||||||
expect(res.text).toEqual(`"clientId" is required`)
|
|
||||||
expect(res.body).toEqual({})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should respond with Forbidden if username is incorrect', async () => {
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/SASjsApi/auth/authorize')
|
|
||||||
.send({
|
|
||||||
username: user.username,
|
|
||||||
password: user.password,
|
|
||||||
clientId
|
|
||||||
})
|
|
||||||
.expect(403)
|
|
||||||
|
|
||||||
expect(res.text).toEqual('Error: Username is not found.')
|
|
||||||
expect(res.body).toEqual({})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should respond with Forbidden if password is incorrect', async () => {
|
|
||||||
await userController.createUser(user)
|
|
||||||
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/SASjsApi/auth/authorize')
|
|
||||||
.send({
|
|
||||||
username: user.username,
|
|
||||||
password: 'WrongPassword',
|
|
||||||
clientId
|
|
||||||
})
|
|
||||||
.expect(403)
|
|
||||||
|
|
||||||
expect(res.text).toEqual('Error: Invalid password.')
|
|
||||||
expect(res.body).toEqual({})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should respond with Forbidden if clientId is incorrect', async () => {
|
|
||||||
await userController.createUser(user)
|
|
||||||
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/SASjsApi/auth/authorize')
|
|
||||||
.send({
|
|
||||||
username: user.username,
|
|
||||||
password: user.password,
|
|
||||||
clientId: 'WrongClientID'
|
|
||||||
})
|
|
||||||
.expect(403)
|
|
||||||
|
|
||||||
expect(res.text).toEqual('Invalid clientId.')
|
|
||||||
expect(res.body).toEqual({})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('token', () => {
|
describe('token', () => {
|
||||||
const userInfo: InfoJWT = {
|
const userInfo: InfoJWT = {
|
||||||
clientId,
|
clientId,
|
||||||
|
|||||||
@@ -5,11 +5,7 @@ import request from 'supertest'
|
|||||||
import appPromise from '../../../app'
|
import appPromise from '../../../app'
|
||||||
import { UserController, ClientController } from '../../../controllers/'
|
import { UserController, ClientController } from '../../../controllers/'
|
||||||
import { generateAccessToken, saveTokensInDB } from '../../../utils'
|
import { generateAccessToken, saveTokensInDB } from '../../../utils'
|
||||||
|
import { NUMBER_OF_SECONDS_IN_A_DAY } from '../../../model/Client'
|
||||||
let app: Express
|
|
||||||
appPromise.then((_app) => {
|
|
||||||
app = _app
|
|
||||||
})
|
|
||||||
|
|
||||||
const client = {
|
const client = {
|
||||||
clientId: 'someclientID',
|
clientId: 'someclientID',
|
||||||
@@ -28,14 +24,30 @@ const newClient = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('client', () => {
|
describe('client', () => {
|
||||||
|
let app: Express
|
||||||
let con: Mongoose
|
let con: Mongoose
|
||||||
let mongoServer: MongoMemoryServer
|
let mongoServer: MongoMemoryServer
|
||||||
|
let adminAccessToken: string
|
||||||
const userController = new UserController()
|
const userController = new UserController()
|
||||||
const clientController = new ClientController()
|
const clientController = new ClientController()
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
app = await appPromise
|
||||||
|
|
||||||
mongoServer = await MongoMemoryServer.create()
|
mongoServer = await MongoMemoryServer.create()
|
||||||
con = await mongoose.connect(mongoServer.getUri())
|
con = await mongoose.connect(mongoServer.getUri())
|
||||||
|
|
||||||
|
const dbUser = await userController.createUser(adminUser)
|
||||||
|
adminAccessToken = generateAccessToken({
|
||||||
|
clientId: client.clientId,
|
||||||
|
userId: dbUser.id
|
||||||
|
})
|
||||||
|
await saveTokensInDB(
|
||||||
|
dbUser.id,
|
||||||
|
client.clientId,
|
||||||
|
adminAccessToken,
|
||||||
|
'refreshToken'
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -45,22 +57,6 @@ describe('client', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('create', () => {
|
describe('create', () => {
|
||||||
let adminAccessToken: string
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
const dbUser = await userController.createUser(adminUser)
|
|
||||||
adminAccessToken = generateAccessToken({
|
|
||||||
clientId: client.clientId,
|
|
||||||
userId: dbUser.id
|
|
||||||
})
|
|
||||||
await saveTokensInDB(
|
|
||||||
dbUser.id,
|
|
||||||
client.clientId,
|
|
||||||
adminAccessToken,
|
|
||||||
'refreshToken'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
const collections = mongoose.connection.collections
|
const collections = mongoose.connection.collections
|
||||||
const collection = collections['clients']
|
const collection = collections['clients']
|
||||||
@@ -159,4 +155,80 @@ describe('client', () => {
|
|||||||
expect(res.body).toEqual({})
|
expect(res.body).toEqual({})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('get', () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
const collections = mongoose.connection.collections
|
||||||
|
const collection = collections['clients']
|
||||||
|
await collection.deleteMany({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should respond with an array of all clients', async () => {
|
||||||
|
await clientController.createClient(newClient)
|
||||||
|
await clientController.createClient({
|
||||||
|
clientId: 'clientID',
|
||||||
|
clientSecret: 'clientSecret'
|
||||||
|
})
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/SASjsApi/client')
|
||||||
|
.auth(adminAccessToken, { type: 'bearer' })
|
||||||
|
.send()
|
||||||
|
.expect(200)
|
||||||
|
|
||||||
|
const expected = [
|
||||||
|
{
|
||||||
|
clientId: 'newClientID',
|
||||||
|
clientSecret: 'newClientSecret',
|
||||||
|
accessTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY,
|
||||||
|
refreshTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY * 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
clientId: 'clientID',
|
||||||
|
clientSecret: 'clientSecret',
|
||||||
|
accessTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY,
|
||||||
|
refreshTokenExpiration: NUMBER_OF_SECONDS_IN_A_DAY * 30
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(res.body).toEqual(expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should respond with Unauthorized if access token is not present', async () => {
|
||||||
|
const res = await request(app).get('/SASjsApi/client').send().expect(401)
|
||||||
|
|
||||||
|
expect(res.text).toEqual('Unauthorized')
|
||||||
|
expect(res.body).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should respond with Forbideen if access token is not of an admin account', async () => {
|
||||||
|
const user = {
|
||||||
|
displayName: 'User 2',
|
||||||
|
username: 'username2',
|
||||||
|
password: '12345678',
|
||||||
|
isAdmin: false,
|
||||||
|
isActive: true
|
||||||
|
}
|
||||||
|
const dbUser = await userController.createUser(user)
|
||||||
|
const accessToken = generateAccessToken({
|
||||||
|
clientId: client.clientId,
|
||||||
|
userId: dbUser.id
|
||||||
|
})
|
||||||
|
await saveTokensInDB(
|
||||||
|
dbUser.id,
|
||||||
|
client.clientId,
|
||||||
|
accessToken,
|
||||||
|
'refreshToken'
|
||||||
|
)
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/SASjsApi/client')
|
||||||
|
.auth(accessToken, { type: 'bearer' })
|
||||||
|
.send()
|
||||||
|
.expect(401)
|
||||||
|
|
||||||
|
expect(res.text).toEqual('Admin account required')
|
||||||
|
expect(res.body).toEqual({})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
1
api/src/routes/api/spec/files/sample.exe
Normal file
1
api/src/routes/api/spec/files/sample.exe
Normal file
@@ -0,0 +1 @@
|
|||||||
|
some code of sas
|
||||||
1
api/src/routes/api/spec/files/sample.sas
Normal file
1
api/src/routes/api/spec/files/sample.sas
Normal file
@@ -0,0 +1 @@
|
|||||||
|
some code of sas
|
||||||
@@ -4,12 +4,13 @@ import { MongoMemoryServer } from 'mongodb-memory-server'
|
|||||||
import request from 'supertest'
|
import request from 'supertest'
|
||||||
import appPromise from '../../../app'
|
import appPromise from '../../../app'
|
||||||
import { UserController, GroupController } from '../../../controllers/'
|
import { UserController, GroupController } from '../../../controllers/'
|
||||||
import { generateAccessToken, saveTokensInDB } from '../../../utils'
|
import {
|
||||||
|
generateAccessToken,
|
||||||
let app: Express
|
saveTokensInDB,
|
||||||
appPromise.then((_app) => {
|
AuthProviderType
|
||||||
app = _app
|
} from '../../../utils'
|
||||||
})
|
import Group, { PUBLIC_GROUP_NAME } from '../../../model/Group'
|
||||||
|
import User from '../../../model/User'
|
||||||
|
|
||||||
const clientId = 'someclientID'
|
const clientId = 'someclientID'
|
||||||
const adminUser = {
|
const adminUser = {
|
||||||
@@ -28,19 +29,28 @@ const user = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const group = {
|
const group = {
|
||||||
name: 'DCGroup1',
|
name: 'dcgroup1',
|
||||||
description: 'DC group for testing purposes.'
|
description: 'DC group for testing purposes.'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PUBLIC_GROUP = {
|
||||||
|
name: PUBLIC_GROUP_NAME,
|
||||||
|
description:
|
||||||
|
'A special group that can be used to bypass authentication for particular routes.'
|
||||||
|
}
|
||||||
|
|
||||||
const userController = new UserController()
|
const userController = new UserController()
|
||||||
const groupController = new GroupController()
|
const groupController = new GroupController()
|
||||||
|
|
||||||
describe('group', () => {
|
describe('group', () => {
|
||||||
|
let app: Express
|
||||||
let con: Mongoose
|
let con: Mongoose
|
||||||
let mongoServer: MongoMemoryServer
|
let mongoServer: MongoMemoryServer
|
||||||
let adminAccessToken: string
|
let adminAccessToken: string
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
app = await appPromise
|
||||||
|
|
||||||
mongoServer = await MongoMemoryServer.create()
|
mongoServer = await MongoMemoryServer.create()
|
||||||
con = await mongoose.connect(mongoServer.getUri())
|
con = await mongoose.connect(mongoServer.getUri())
|
||||||
|
|
||||||
@@ -72,6 +82,32 @@ describe('group', () => {
|
|||||||
expect(res.body.users).toEqual([])
|
expect(res.body.users).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should respond with Conflict when group already exists with same name', async () => {
|
||||||
|
await groupController.createGroup(group)
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/SASjsApi/group')
|
||||||
|
.auth(adminAccessToken, { type: 'bearer' })
|
||||||
|
.send(group)
|
||||||
|
.expect(409)
|
||||||
|
|
||||||
|
expect(res.text).toEqual('Group name already exists.')
|
||||||
|
expect(res.body).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should respond with Bad Request when group name does not match the group name schema', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/SASjsApi/group')
|
||||||
|
.auth(adminAccessToken, { type: 'bearer' })
|
||||||
|
.send({ ...group, name: 'Wrong Group Name' })
|
||||||
|
.expect(400)
|
||||||
|
|
||||||
|
expect(res.text).toEqual(
|
||||||
|
'"name" must only contain alpha-numeric characters'
|
||||||
|
)
|
||||||
|
expect(res.body).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
it('should respond with Unauthorized if access token is not present', async () => {
|
it('should respond with Unauthorized if access token is not present', async () => {
|
||||||
const res = await request(app).post('/SASjsApi/group').send().expect(401)
|
const res = await request(app).post('/SASjsApi/group').send().expect(401)
|
||||||
|
|
||||||
@@ -127,14 +163,51 @@ describe('group', () => {
|
|||||||
expect(res.body).toEqual({})
|
expect(res.body).toEqual({})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should respond with Forbidden if groupId is incorrect', async () => {
|
it(`should delete group's reference from users' groups array`, async () => {
|
||||||
|
const dbGroup = await groupController.createGroup(group)
|
||||||
|
const dbUser1 = await userController.createUser({
|
||||||
|
...user,
|
||||||
|
username: 'deletegroup1'
|
||||||
|
})
|
||||||
|
const dbUser2 = await userController.createUser({
|
||||||
|
...user,
|
||||||
|
username: 'deletegroup2'
|
||||||
|
})
|
||||||
|
|
||||||
|
await groupController.addUserToGroup(dbGroup.groupId, dbUser1.id)
|
||||||
|
await groupController.addUserToGroup(dbGroup.groupId, dbUser2.id)
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.delete(`/SASjsApi/group/${dbGroup.groupId}`)
|
||||||
|
.auth(adminAccessToken, { type: 'bearer' })
|
||||||
|
.send()
|
||||||
|
.expect(200)
|
||||||
|
|
||||||
|
const res1 = await request(app)
|
||||||
|
.get(`/SASjsApi/user/${dbUser1.id}`)
|
||||||
|
.auth(adminAccessToken, { type: 'bearer' })
|
||||||
|
.send()
|
||||||
|
.expect(200)
|
||||||
|
|
||||||
|
expect(res1.body.groups).toEqual([])
|
||||||
|
|
||||||
|
const res2 = await request(app)
|
||||||
|
.get(`/SASjsApi/user/${dbUser2.id}`)
|
||||||
|
.auth(adminAccessToken, { type: 'bearer' })
|
||||||
|
.send()
|
||||||
|
.expect(200)
|
||||||
|
|
||||||
|
expect(res2.body.groups).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should respond with Not Found if groupId is incorrect', async () => {
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.delete(`/SASjsApi/group/1234`)
|
.delete(`/SASjsApi/group/1234`)
|
||||||
.auth(adminAccessToken, { type: 'bearer' })
|
.auth(adminAccessToken, { type: 'bearer' })
|
||||||
.send()
|
.send()
|
||||||
.expect(403)
|
.expect(404)
|
||||||
|
|
||||||
expect(res.text).toEqual('Error: No Group deleted!')
|
expect(res.text).toEqual('Group not found.')
|
||||||
expect(res.body).toEqual({})
|
expect(res.body).toEqual({})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -218,16 +291,76 @@ describe('group', () => {
|
|||||||
expect(res.body).toEqual({})
|
expect(res.body).toEqual({})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should respond with Forbidden if groupId is incorrect', async () => {
|
it('should respond with Not Found if groupId is incorrect', async () => {
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.get('/SASjsApi/group/1234')
|
.get('/SASjsApi/group/1234')
|
||||||
.auth(adminAccessToken, { type: 'bearer' })
|
.auth(adminAccessToken, { type: 'bearer' })
|
||||||
.send()
|
.send()
|
||||||
.expect(403)
|
.expect(404)
|
||||||
|
|
||||||
expect(res.text).toEqual('Error: Group not found.')
|
expect(res.text).toEqual('Group not found.')
|
||||||
expect(res.body).toEqual({})
|
expect(res.body).toEqual({})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('by group name', () => {
|
||||||
|
it('should respond with group', async () => {
|
||||||
|
const { name } = await groupController.createGroup(group)
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/SASjsApi/group/by/groupname/${name}`)
|
||||||
|
.auth(adminAccessToken, { type: 'bearer' })
|
||||||
|
.send()
|
||||||
|
.expect(200)
|
||||||
|
|
||||||
|
expect(res.body.groupId).toBeTruthy()
|
||||||
|
expect(res.body.name).toEqual(group.name)
|
||||||
|
expect(res.body.description).toEqual(group.description)
|
||||||
|
expect(res.body.isActive).toEqual(true)
|
||||||
|
expect(res.body.users).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should respond with group when access token is not of an admin account', async () => {
|
||||||
|
const accessToken = await generateSaveTokenAndCreateUser({
|
||||||
|
...user,
|
||||||
|
username: 'getbyname' + user.username
|
||||||
|
})
|
||||||
|
|
||||||
|
const { name } = await groupController.createGroup(group)
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/SASjsApi/group/by/groupname/${name}`)
|
||||||
|
.auth(accessToken, { type: 'bearer' })
|
||||||
|
.send()
|
||||||
|
.expect(200)
|
||||||
|
|
||||||
|
expect(res.body.groupId).toBeTruthy()
|
||||||
|
expect(res.body.name).toEqual(group.name)
|
||||||
|
expect(res.body.description).toEqual(group.description)
|
||||||
|
expect(res.body.isActive).toEqual(true)
|
||||||
|
expect(res.body.users).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should respond with Unauthorized if access token is not present', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/SASjsApi/group/by/groupname/dcgroup')
|
||||||
|
.send()
|
||||||
|
.expect(401)
|
||||||
|
|
||||||
|
expect(res.text).toEqual('Unauthorized')
|
||||||
|
expect(res.body).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should respond with Not Found if groupname is incorrect', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/SASjsApi/group/by/groupname/randomCharacters')
|
||||||
|
.auth(adminAccessToken, { type: 'bearer' })
|
||||||
|
.send()
|
||||||
|
.expect(404)
|
||||||
|
|
||||||
|
expect(res.text).toEqual('Group not found.')
|
||||||
|
expect(res.body).toEqual({})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('getAll', () => {
|
describe('getAll', () => {
|
||||||
@@ -247,8 +380,8 @@ describe('group', () => {
|
|||||||
expect(res.body).toEqual([
|
expect(res.body).toEqual([
|
||||||
{
|
{
|
||||||
groupId: expect.anything(),
|
groupId: expect.anything(),
|
||||||
name: 'DCGroup1',
|
name: group.name,
|
||||||
description: 'DC group for testing purposes.'
|
description: group.description
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
@@ -269,8 +402,8 @@ describe('group', () => {
|
|||||||
expect(res.body).toEqual([
|
expect(res.body).toEqual([
|
||||||
{
|
{
|
||||||
groupId: expect.anything(),
|
groupId: expect.anything(),
|
||||||
name: 'DCGroup1',
|
name: group.name,
|
||||||
description: 'DC group for testing purposes.'
|
description: group.description
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
@@ -311,6 +444,34 @@ describe('group', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it(`should add group to user's groups array`, async () => {
|
||||||
|
const dbGroup = await groupController.createGroup(group)
|
||||||
|
const dbUser = await userController.createUser({
|
||||||
|
...user,
|
||||||
|
username: 'addUserToGroup'
|
||||||
|
})
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
|
||||||
|
.auth(adminAccessToken, { type: 'bearer' })
|
||||||
|
.send()
|
||||||
|
.expect(200)
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/SASjsApi/user/${dbUser.id}`)
|
||||||
|
.auth(adminAccessToken, { type: 'bearer' })
|
||||||
|
.send()
|
||||||
|
.expect(200)
|
||||||
|
|
||||||
|
expect(res.body.groups).toEqual([
|
||||||
|
{
|
||||||
|
groupId: expect.anything(),
|
||||||
|
name: group.name,
|
||||||
|
description: group.description
|
||||||
|
}
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
it('should respond with group without duplicating user', async () => {
|
it('should respond with group without duplicating user', async () => {
|
||||||
const dbGroup = await groupController.createGroup(group)
|
const dbGroup = await groupController.createGroup(group)
|
||||||
const dbUser = await userController.createUser({
|
const dbUser = await userController.createUser({
|
||||||
@@ -364,28 +525,86 @@ describe('group', () => {
|
|||||||
expect(res.body).toEqual({})
|
expect(res.body).toEqual({})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should respond with Forbidden if groupId is incorrect', async () => {
|
it('should respond with Not Found if groupId is incorrect', async () => {
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post('/SASjsApi/group/123/123')
|
.post('/SASjsApi/group/123/123')
|
||||||
.auth(adminAccessToken, { type: 'bearer' })
|
.auth(adminAccessToken, { type: 'bearer' })
|
||||||
.send()
|
.send()
|
||||||
.expect(403)
|
.expect(404)
|
||||||
|
|
||||||
expect(res.text).toEqual('Error: Group not found.')
|
expect(res.text).toEqual('Group not found.')
|
||||||
expect(res.body).toEqual({})
|
expect(res.body).toEqual({})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should respond with Forbidden if userId is incorrect', async () => {
|
it('should respond with Not Found if userId is incorrect', async () => {
|
||||||
const dbGroup = await groupController.createGroup(group)
|
const dbGroup = await groupController.createGroup(group)
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post(`/SASjsApi/group/${dbGroup.groupId}/123`)
|
.post(`/SASjsApi/group/${dbGroup.groupId}/123`)
|
||||||
.auth(adminAccessToken, { type: 'bearer' })
|
.auth(adminAccessToken, { type: 'bearer' })
|
||||||
.send()
|
.send()
|
||||||
.expect(403)
|
.expect(404)
|
||||||
|
|
||||||
expect(res.text).toEqual('Error: User not found.')
|
expect(res.text).toEqual('User not found.')
|
||||||
expect(res.body).toEqual({})
|
expect(res.body).toEqual({})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should respond with Bad Request when adding user to Public group', async () => {
|
||||||
|
const dbGroup = await groupController.createGroup(PUBLIC_GROUP)
|
||||||
|
const dbUser = await userController.createUser({
|
||||||
|
...user,
|
||||||
|
username: 'publicUser'
|
||||||
|
})
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
|
||||||
|
.auth(adminAccessToken, { type: 'bearer' })
|
||||||
|
.send()
|
||||||
|
.expect(400)
|
||||||
|
|
||||||
|
expect(res.text).toEqual(
|
||||||
|
`Can't add/remove user to '${PUBLIC_GROUP_NAME}' group.`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should respond with Method Not Allowed if group is created by an external authProvider', async () => {
|
||||||
|
const dbGroup = await Group.create({
|
||||||
|
...group,
|
||||||
|
authProvider: AuthProviderType.LDAP
|
||||||
|
})
|
||||||
|
const dbUser = await userController.createUser({
|
||||||
|
...user,
|
||||||
|
username: 'ldapGroupUser'
|
||||||
|
})
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
|
||||||
|
.auth(adminAccessToken, { type: 'bearer' })
|
||||||
|
.send()
|
||||||
|
.expect(405)
|
||||||
|
|
||||||
|
expect(res.text).toEqual(
|
||||||
|
`Can't add/remove user to group created by external auth provider.`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should respond with Method Not Allowed if user is created by an external authProvider', async () => {
|
||||||
|
const dbGroup = await groupController.createGroup(group)
|
||||||
|
const dbUser = await User.create({
|
||||||
|
...user,
|
||||||
|
username: 'ldapUser',
|
||||||
|
authProvider: AuthProviderType.LDAP
|
||||||
|
})
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
|
||||||
|
.auth(adminAccessToken, { type: 'bearer' })
|
||||||
|
.send()
|
||||||
|
.expect(405)
|
||||||
|
|
||||||
|
expect(res.text).toEqual(
|
||||||
|
`Can't add/remove user to group created by external auth provider.`
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('RemoveUser', () => {
|
describe('RemoveUser', () => {
|
||||||
@@ -414,6 +633,69 @@ describe('group', () => {
|
|||||||
expect(res.body.users).toEqual([])
|
expect(res.body.users).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it(`should remove group from user's groups array`, async () => {
|
||||||
|
const dbGroup = await groupController.createGroup(group)
|
||||||
|
const dbUser = await userController.createUser({
|
||||||
|
...user,
|
||||||
|
username: 'removeGroupFromUser'
|
||||||
|
})
|
||||||
|
await groupController.addUserToGroup(dbGroup.groupId, dbUser.id)
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.delete(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
|
||||||
|
.auth(adminAccessToken, { type: 'bearer' })
|
||||||
|
.send()
|
||||||
|
.expect(200)
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/SASjsApi/user/${dbUser.id}`)
|
||||||
|
.auth(adminAccessToken, { type: 'bearer' })
|
||||||
|
.send()
|
||||||
|
.expect(200)
|
||||||
|
|
||||||
|
expect(res.body.groups).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should respond with Method Not Allowed if group is created by an external authProvider', async () => {
|
||||||
|
const dbGroup = await Group.create({
|
||||||
|
...group,
|
||||||
|
authProvider: AuthProviderType.LDAP
|
||||||
|
})
|
||||||
|
const dbUser = await userController.createUser({
|
||||||
|
...user,
|
||||||
|
username: 'removeLdapGroupUser'
|
||||||
|
})
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.delete(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
|
||||||
|
.auth(adminAccessToken, { type: 'bearer' })
|
||||||
|
.send()
|
||||||
|
.expect(405)
|
||||||
|
|
||||||
|
expect(res.text).toEqual(
|
||||||
|
`Can't add/remove user to group created by external auth provider.`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should respond with Method Not Allowed if user is created by an external authProvider', async () => {
|
||||||
|
const dbGroup = await groupController.createGroup(group)
|
||||||
|
const dbUser = await User.create({
|
||||||
|
...user,
|
||||||
|
username: 'removeLdapUser',
|
||||||
|
authProvider: AuthProviderType.LDAP
|
||||||
|
})
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.delete(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
|
||||||
|
.auth(adminAccessToken, { type: 'bearer' })
|
||||||
|
.send()
|
||||||
|
.expect(405)
|
||||||
|
|
||||||
|
expect(res.text).toEqual(
|
||||||
|
`Can't add/remove user to group created by external auth provider.`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('should respond with Unauthorized if access token is not present', async () => {
|
it('should respond with Unauthorized if access token is not present', async () => {
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.delete('/SASjsApi/group/123/123')
|
.delete('/SASjsApi/group/123/123')
|
||||||
@@ -440,26 +722,26 @@ describe('group', () => {
|
|||||||
expect(res.body).toEqual({})
|
expect(res.body).toEqual({})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should respond with Forbidden if groupId is incorrect', async () => {
|
it('should respond with Not Found if groupId is incorrect', async () => {
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.delete('/SASjsApi/group/123/123')
|
.delete('/SASjsApi/group/123/123')
|
||||||
.auth(adminAccessToken, { type: 'bearer' })
|
.auth(adminAccessToken, { type: 'bearer' })
|
||||||
.send()
|
.send()
|
||||||
.expect(403)
|
.expect(404)
|
||||||
|
|
||||||
expect(res.text).toEqual('Error: Group not found.')
|
expect(res.text).toEqual('Group not found.')
|
||||||
expect(res.body).toEqual({})
|
expect(res.body).toEqual({})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should respond with Forbidden if userId is incorrect', async () => {
|
it('should respond with Not Found if userId is incorrect', async () => {
|
||||||
const dbGroup = await groupController.createGroup(group)
|
const dbGroup = await groupController.createGroup(group)
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.delete(`/SASjsApi/group/${dbGroup.groupId}/123`)
|
.delete(`/SASjsApi/group/${dbGroup.groupId}/123`)
|
||||||
.auth(adminAccessToken, { type: 'bearer' })
|
.auth(adminAccessToken, { type: 'bearer' })
|
||||||
.send()
|
.send()
|
||||||
.expect(403)
|
.expect(404)
|
||||||
|
|
||||||
expect(res.text).toEqual('Error: User not found.')
|
expect(res.text).toEqual('User not found.')
|
||||||
expect(res.body).toEqual({})
|
expect(res.body).toEqual({})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
20
api/src/routes/api/spec/info.spec.ts
Normal file
20
api/src/routes/api/spec/info.spec.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Express } from 'express'
|
||||||
|
import request from 'supertest'
|
||||||
|
import appPromise from '../../../app'
|
||||||
|
|
||||||
|
describe('Info', () => {
|
||||||
|
let app: Express
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = await appPromise
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should should return configured information of the server instance', async () => {
|
||||||
|
const res = await request(app).get('/SASjsApi/info').expect(200)
|
||||||
|
|
||||||
|
expect(res.body.mode).toEqual('server')
|
||||||
|
expect(res.body.cors).toEqual('disable')
|
||||||
|
expect(res.body.whiteList).toEqual([])
|
||||||
|
expect(res.body.protocol).toEqual('http')
|
||||||
|
})
|
||||||
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user