Compare commits
954 Commits
refactor/c
...
2024.7.0-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7efffcd79 | ||
|
|
4fd6d6ddb4 | ||
|
|
40f93fc7f4 | ||
|
|
adb7782b58 | ||
|
|
a9fc7e6a62 | ||
|
|
d68cfb313e | ||
|
|
2a37235229 | ||
|
|
e26528cedf | ||
|
|
c9f92282bf | ||
|
|
c24d5c5302 | ||
|
|
783d911f8d | ||
|
|
c88ea5c8b2 | ||
|
|
5f96cda5e2 | ||
|
|
ecf0901491 | ||
|
|
9e445cda84 | ||
|
|
9cde6c597b | ||
|
|
df730e4d21 | ||
|
|
4457eeb74c | ||
|
|
bf5d62364d | ||
|
|
f913899ad5 | ||
|
|
7652dab9c4 | ||
|
|
801df8ea97 | ||
|
|
25a519a2e9 | ||
|
|
8c6b80dc42 | ||
|
|
7ebc9a6fd6 | ||
|
|
7ad3f660ae | ||
|
|
199a7234fa | ||
|
|
90bb470b3a | ||
|
|
1dee1e1b1a | ||
|
|
71ac013a38 | ||
|
|
fe79c47d1f | ||
|
|
2917d50c6a | ||
|
|
0c06f26893 | ||
|
|
fba22ea687 | ||
|
|
fd60c630fd | ||
|
|
7deb49b244 | ||
|
|
ac84166b8b | ||
|
|
07e7180dc9 | ||
|
|
1d1462df69 | ||
|
|
b851d3003c | ||
|
|
3b70668162 | ||
|
|
a9afb17dc0 | ||
|
|
fa2f73ee40 | ||
|
|
b3e42bf7c3 | ||
|
|
25fd35770a | ||
|
|
762cd2207b | ||
|
|
1da5dde0ba | ||
|
|
190a3b8eaf | ||
|
|
aead9e6c98 | ||
|
|
5e3bc01922 | ||
|
|
9479258acb | ||
|
|
7baabebcee | ||
|
|
6e63c723b7 | ||
|
|
fac90ac342 | ||
|
|
9d02c8e6c9 | ||
|
|
d45f15e9cb | ||
|
|
021ecf17ce | ||
|
|
257974325e | ||
|
|
c2085b8b6f | ||
|
|
cfb77f2bfe | ||
|
|
292d752f32 | ||
|
|
73090c7a2b | ||
|
|
e6cd03c283 | ||
|
|
cc1be91446 | ||
|
|
c2cb295bcb | ||
|
|
31f1e1b21a | ||
|
|
93807bfe8f | ||
|
|
e3ad0c9e2e | ||
|
|
465ea2b4e0 | ||
|
|
f13478da86 | ||
|
|
5805826994 | ||
|
|
5fd7c28894 | ||
|
|
b601a2f55f | ||
|
|
bece13e6b0 | ||
|
|
d0350ec789 | ||
|
|
5c214a8da0 | ||
|
|
2c0805fafe | ||
|
|
26b4f64824 | ||
|
|
4156551b24 | ||
|
|
4bd23a8f4c | ||
|
|
f4f3fdf2d5 | ||
|
|
b7a3ae231b | ||
|
|
f8ac6dfeb1 | ||
|
|
7d2d335b37 | ||
|
|
76875db865 | ||
|
|
96e2d87b57 | ||
|
|
be353d9f72 | ||
|
|
38bc2c12c3 | ||
|
|
97644fa508 | ||
|
|
eb3446ae23 | ||
|
|
6c29961d09 | ||
|
|
ef1117d8cc | ||
|
|
5c4b651aee | ||
|
|
391e5a20f5 | ||
|
|
4b8f3bd8da | ||
|
|
94248076e6 | ||
|
|
eecc3db4e9 | ||
|
|
426e7594f4 | ||
|
|
934dc473f0 | ||
|
|
be57255bf7 | ||
|
|
f89561da54 | ||
|
|
c2c4e620c2 | ||
|
|
844eee0fa4 | ||
|
|
d21bb65511 | ||
|
|
4f614f7257 | ||
|
|
0e2887b4e9 | ||
|
|
18652ce400 | ||
|
|
08c655235d | ||
|
|
51412549e8 | ||
|
|
22c6eabd13 | ||
|
|
a079e0f04b | ||
|
|
375d53263a | ||
|
|
57ef3e085f | ||
|
|
9fb6e59e36 | ||
|
|
1b0802b0e6 | ||
|
|
fb45fe4627 | ||
|
|
0f592d1789 | ||
|
|
787aab650f | ||
|
|
1f7a8edb14 | ||
|
|
81f1e05a6c | ||
|
|
0a71783eaa | ||
|
|
c326f54f7e | ||
|
|
1113c79e20 | ||
|
|
6fd30f9aca | ||
|
|
2c5b0dcd1b | ||
|
|
6f4455ba03 | ||
|
|
ba8c4480d9 | ||
|
|
380397cc55 | ||
|
|
d19807b212 | ||
|
|
c89c2a5f5c | ||
|
|
256553b9bb | ||
|
|
89d9951f3b | ||
|
|
dd65ad3103 | ||
|
|
018ed3db26 | ||
|
|
a9cd6c0c01 | ||
|
|
e53382666a | ||
|
|
7621ff2961 | ||
|
|
fc20b76080 | ||
|
|
146c73d7b6 | ||
|
|
6b58915caa | ||
|
|
457857a711 | ||
|
|
a3f3e3e62d | ||
|
|
66f20d10e1 | ||
|
|
32e9366609 | ||
|
|
e41e956273 | ||
|
|
a14870f3f0 | ||
|
|
0e96665254 | ||
|
|
efdc1c2f5d | ||
|
|
c5334d4c06 | ||
|
|
4f549974ed | ||
|
|
41d617b507 | ||
|
|
be7387ed19 | ||
|
|
acfb0189df | ||
|
|
8fdba760a2 | ||
|
|
bf98009abb | ||
|
|
dce396c164 | ||
|
|
07e8af7947 | ||
|
|
e69d5a6253 | ||
|
|
6d66d12a9e | ||
|
|
439cd82c88 | ||
|
|
6dbaf524ce | ||
|
|
68e439d1a4 | ||
|
|
8deba7a28e | ||
|
|
7ec8659381 | ||
|
|
3611cac241 | ||
|
|
919579b1da | ||
|
|
4798d7bbbd | ||
|
|
a0c6b22641 | ||
|
|
de8929ab18 | ||
|
|
55a94bdccc | ||
|
|
faab1d20fd | ||
|
|
bd406616ec | ||
|
|
6827e97ec5 | ||
|
|
10d2048975 | ||
|
|
291f18591e | ||
|
|
342532c9b1 | ||
|
|
cf039c482a | ||
|
|
ded2725116 | ||
|
|
9c6754c70f | ||
|
|
4bd54b12cd | ||
|
|
ed6e9b6954 | ||
|
|
dfdd44b4ed | ||
|
|
fc34871dae | ||
|
|
45b532747e | ||
|
|
de4635df23 | ||
|
|
41bad1f3dc | ||
|
|
ecca3d2032 | ||
|
|
47226be6d0 | ||
|
|
6a0e73fdec | ||
|
|
672ee69b2c | ||
|
|
b359650d96 | ||
|
|
c0fae79678 | ||
|
|
5bcc38e36b | ||
|
|
00862eb192 | ||
|
|
16803acb26 | ||
|
|
3911c9cd1f | ||
|
|
0028f6e878 | ||
|
|
0ba33ec187 | ||
|
|
3482743782 | ||
|
|
d7cdeb796a | ||
|
|
3d6adcc39d | ||
|
|
aab76f1358 | ||
|
|
a28a576c41 | ||
|
|
0d0ad7a2f8 | ||
|
|
1df9de44b7 | ||
|
|
4cba03e53f | ||
|
|
9e1466a877 | ||
|
|
b81ccb4ee3 | ||
|
|
27d0a7c437 | ||
|
|
aca96dd5f2 | ||
|
|
c0dbcc901f | ||
|
|
ba52c8cc37 | ||
|
|
d1f6f40ef8 | ||
|
|
99f5070f71 | ||
|
|
cd371fc9d4 | ||
|
|
59fef248c0 | ||
|
|
286fcd2bb0 | ||
|
|
b2d98f7b66 | ||
|
|
c6c220091a | ||
|
|
8f503479b6 | ||
|
|
54d8378ccf | ||
|
|
0df194f9c5 | ||
|
|
ddf7eb6ad6 | ||
|
|
7db7b9b068 | ||
|
|
3d25ef48d1 | ||
|
|
4f138beb8a | ||
|
|
3d7a76bced | ||
|
|
74359ea74e | ||
|
|
a694d3f7eb | ||
|
|
58a9514b67 | ||
|
|
a75bfa9d9e | ||
|
|
7374a35b41 | ||
|
|
5ad8f6c2ce | ||
|
|
f28298afe7 | ||
|
|
56c6e8c643 | ||
|
|
1b36de4fa3 | ||
|
|
2f773bec79 | ||
|
|
d3e04c59cc | ||
|
|
5179cf59a4 | ||
|
|
fad31a47ee | ||
|
|
72c71ddbd4 | ||
|
|
a0f5ebee39 | ||
|
|
f93558324f | ||
|
|
d80e6c01c8 | ||
|
|
06f0f1c91b | ||
|
|
9b870f876a | ||
|
|
cf8b5975ac | ||
|
|
93082c3816 | ||
|
|
d66537ac34 | ||
|
|
fc4c15e52d | ||
|
|
b521604b66 | ||
|
|
9bc81a6d67 | ||
|
|
c47e2e7767 | ||
|
|
5209c0a8ca | ||
|
|
47e009267b | ||
|
|
f3edd001d7 | ||
|
|
a8cc569786 | ||
|
|
3ae49ca483 | ||
|
|
37e6497e88 | ||
|
|
b522ae9e05 | ||
|
|
62b11fcec8 | ||
|
|
51ebb57623 | ||
|
|
ff5c2ba51c | ||
|
|
6abc0e6071 | ||
|
|
957641fb0f | ||
|
|
a55f214102 | ||
|
|
ebf90207e5 | ||
|
|
4ac8a117ef | ||
|
|
c1bc430ee6 | ||
|
|
9201aa7d7d | ||
|
|
87395a4553 | ||
|
|
6063c633ee | ||
|
|
7481feb366 | ||
|
|
bdfa14fa54 | ||
|
|
0a61ec2bfe | ||
|
|
2bf0106aa2 | ||
|
|
ab7c29d228 | ||
|
|
d9c75ed79e | ||
|
|
6fa722df7b | ||
|
|
18864bfecf | ||
|
|
95754cb2b4 | ||
|
|
ed2a461dc5 | ||
|
|
8d5a456dbd | ||
|
|
2528bbb92f | ||
|
|
259cd48dbb | ||
|
|
b43531f200 | ||
|
|
26da3e18a9 | ||
|
|
bb4b640e58 | ||
|
|
1cc845e17d | ||
|
|
60bfb6fe2c | ||
|
|
144d14ab5b | ||
|
|
8f1ca6e282 | ||
|
|
a93758c6b7 | ||
|
|
1829c088cc | ||
|
|
ee1425d0dd | ||
|
|
24ae090916 | ||
|
|
a3aa9b68fc | ||
|
|
50f475334e | ||
|
|
7b18526f24 | ||
|
|
23afc201a1 | ||
|
|
b1982d74a6 | ||
|
|
e93a37c711 | ||
|
|
8d7509cdea | ||
|
|
e24d0ce605 | ||
|
|
f5d2e4f11f | ||
|
|
de725337d6 | ||
|
|
9d1d369f37 | ||
|
|
2bd925d441 | ||
|
|
bb8dc6f7eb | ||
|
|
be3e5ba7e7 | ||
|
|
663134839f | ||
|
|
736f83a70c | ||
|
|
05d2175f43 | ||
|
|
4caf0053cd | ||
|
|
97bd808431 | ||
|
|
a13c2fd4c1 | ||
|
|
16044b5840 | ||
|
|
93ce86f32d | ||
|
|
4ebf850cb6 | ||
|
|
76af7d5e10 | ||
|
|
507fe69efe | ||
|
|
23e3739718 | ||
|
|
5428a73811 | ||
|
|
4a154e6569 | ||
|
|
0aa5825d8b | ||
|
|
bdb63e99d5 | ||
|
|
6daa043a1b | ||
|
|
8175ec640a | ||
|
|
b5307e4a89 | ||
|
|
19294802be | ||
|
|
cbe3e14b47 | ||
|
|
9dcbc4a126 | ||
|
|
01df1663ad | ||
|
|
a215860782 | ||
|
|
abd5288da8 | ||
|
|
a89bc473f6 | ||
|
|
59b5a50a97 | ||
|
|
57cb59027b | ||
|
|
d1c9c3583f | ||
|
|
2462492c86 | ||
|
|
7a9f0c8756 | ||
|
|
46caf9b198 | ||
|
|
f5db54484c | ||
|
|
8deb6471b9 | ||
|
|
73b3ff8e41 | ||
|
|
016a18d3b2 | ||
|
|
ba31cdabea | ||
|
|
51510566bc | ||
|
|
cabee0ecc8 | ||
|
|
2c2b39a236 | ||
|
|
78450c9316 | ||
|
|
b18fd90b64 | ||
|
|
0188a8d7db | ||
|
|
6c63a8dc28 | ||
|
|
17d6ae15a5 | ||
|
|
40f72278a9 | ||
|
|
f717704731 | ||
|
|
185c225297 | ||
|
|
2694731c36 | ||
|
|
ae89af9978 | ||
|
|
87d617012f | ||
|
|
2420b3fa42 | ||
|
|
175a991ec4 | ||
|
|
0301649aff | ||
|
|
544b045300 | ||
|
|
65884293be | ||
|
|
3cb4861bac | ||
|
|
7beed30815 | ||
|
|
bb380f3751 | ||
|
|
33a7580e46 | ||
|
|
ffb2b5c30a | ||
|
|
7c238fa854 | ||
|
|
185b575e5b | ||
|
|
bcc1147f81 | ||
|
|
f5b130024e | ||
|
|
bb5c333bae | ||
|
|
3684d25848 | ||
|
|
8b0ba3a45e | ||
|
|
e847fb7b77 | ||
|
|
5c78ae4dee | ||
|
|
53ec605963 | ||
|
|
75193a7aa8 | ||
|
|
b269c239d9 | ||
|
|
72b4a1fc4e | ||
|
|
d2d1674d31 | ||
|
|
a6b57777e3 | ||
|
|
65ef4db86f | ||
|
|
7201147b55 | ||
|
|
dd143c95a9 | ||
|
|
005581ee7d | ||
|
|
1431ecc6d7 | ||
|
|
f34d896095 | ||
|
|
e95ebb9226 | ||
|
|
57365eeae0 | ||
|
|
b22bd97818 | ||
|
|
b953b32ff4 | ||
|
|
0eacd6763b | ||
|
|
8499ac7fec | ||
|
|
4adac4af38 | ||
|
|
fd162e242c | ||
|
|
3e83828722 | ||
|
|
f7dc36e3f1 | ||
|
|
a7566dfd86 | ||
|
|
d4d7a20fbd | ||
|
|
dfb281bcf7 | ||
|
|
c62482e81f | ||
|
|
886847ab7b | ||
|
|
a268cab11e | ||
|
|
e9509b9fa1 | ||
|
|
8db452089c | ||
|
|
a1764023f3 | ||
|
|
b08b63dc73 | ||
|
|
a9a4ebf595 | ||
|
|
a8e279db28 | ||
|
|
d09a3e9237 | ||
|
|
efa40cf6ea | ||
|
|
1a3d9f18ab | ||
|
|
653ccd3240 | ||
|
|
c0806cfd07 | ||
|
|
008eb6b77b | ||
|
|
ac60843183 | ||
|
|
3c3fb1e4a9 | ||
|
|
88212e8cfe | ||
|
|
191fa376d2 | ||
|
|
6efae3a395 | ||
|
|
cb8678f07f | ||
|
|
b32b0f9bcb | ||
|
|
5a91fb53b2 | ||
|
|
b0b6edc58e | ||
|
|
8c57d81718 | ||
|
|
10bb68a538 | ||
|
|
d4d1e27ba9 | ||
|
|
d5c887f311 | ||
|
|
ce7adf6da3 | ||
|
|
c626fb9241 | ||
|
|
f21ed30e10 | ||
|
|
b55970cc7a | ||
|
|
74ad2e43a4 | ||
|
|
2d6282cf8b | ||
|
|
e255c46455 | ||
|
|
15c2c7bb5b | ||
|
|
71bcd22444 | ||
|
|
2d104160f2 | ||
|
|
f7c1825de5 | ||
|
|
2c1fd5d711 | ||
|
|
085fbb2a9b | ||
|
|
05f2d8817b | ||
|
|
81fbb22c51 | ||
|
|
01cf59c663 | ||
|
|
5c8ebaff3e | ||
|
|
0e70c28324 | ||
|
|
88f6a4ae26 | ||
|
|
610538ca02 | ||
|
|
8970ff5c68 | ||
|
|
d1a564d5b8 | ||
|
|
8bb1d19c07 | ||
|
|
c1efa381f0 | ||
|
|
29171d1b6f | ||
|
|
e869d49e16 | ||
|
|
6496bea846 | ||
|
|
39842559b5 | ||
|
|
51efb35aa6 | ||
|
|
9402bb9285 | ||
|
|
5a516f7242 | ||
|
|
3b217d78e7 | ||
|
|
8e153b38dc | ||
|
|
6f38bfb148 | ||
|
|
82b6e08d68 | ||
|
|
31fd6567b7 | ||
|
|
25177bd635 | ||
|
|
6928eb7992 | ||
|
|
8300f9a0a2 | ||
|
|
525ba77739 | ||
|
|
6bc748a267 | ||
|
|
5230d2d3b8 | ||
|
|
c3531c9d8b | ||
|
|
b29c04c28d | ||
|
|
b2af353941 | ||
|
|
9dbce74f5e | ||
|
|
db1cf5cc08 | ||
|
|
09360abf81 | ||
|
|
355bd62b8d | ||
|
|
5650de1183 | ||
|
|
2ee8614b93 | ||
|
|
5632334c9a | ||
|
|
780dd8a713 | ||
|
|
7db3c6d290 | ||
|
|
c765270dfe | ||
|
|
03f667c21d | ||
|
|
f79f3078dc | ||
|
|
6e29a2f6d4 | ||
|
|
6304fd50c3 | ||
|
|
2ec29c47ad | ||
|
|
399a238bf4 | ||
|
|
b20ab72298 | ||
|
|
f723e6496a | ||
|
|
8c0aff8863 | ||
|
|
64c5077506 | ||
|
|
2afc87847d | ||
|
|
878ec833ce | ||
|
|
039de8015f | ||
|
|
f67b366b90 | ||
|
|
6f35574d68 | ||
|
|
77e8a36ab0 | ||
|
|
d7cc9f5dbc | ||
|
|
fc3e3aeaec | ||
|
|
4ba135f3b9 | ||
|
|
24894e05dc | ||
|
|
e2b668bee2 | ||
|
|
f112c46bb4 | ||
|
|
331d482b22 | ||
|
|
b07243f131 | ||
|
|
84b0c30d64 | ||
|
|
e3dd9e99a1 | ||
|
|
e3091cb6db | ||
|
|
270f796683 | ||
|
|
24c6bce02d | ||
|
|
2db567589f | ||
|
|
1fe83ebdc8 | ||
|
|
8320d4f222 | ||
|
|
e76c1bc64c | ||
|
|
1f3f8464ea | ||
|
|
81a7e23a12 | ||
|
|
e75391cdf1 | ||
|
|
a213c0c26c | ||
|
|
15424903ed | ||
|
|
1cce117b0a | ||
|
|
abc7b4b6f3 | ||
|
|
05e32ef9e4 | ||
|
|
f0a1fc319c | ||
|
|
385cabc6aa | ||
|
|
397b26a9f3 | ||
|
|
9a40058329 | ||
|
|
7ec2380ed5 | ||
|
|
3d4825305d | ||
|
|
26e564288b | ||
|
|
385a587cfd | ||
|
|
215df02783 | ||
|
|
7c7ed68b20 | ||
|
|
c910a0314a | ||
|
|
ddaec1b9ac | ||
|
|
9dbdef9286 | ||
|
|
e77eef1532 | ||
|
|
1fe0b8861d | ||
|
|
aeb9172144 | ||
|
|
1b413e2f47 | ||
|
|
d6c8400116 | ||
|
|
4a0205e622 | ||
|
|
c2520006ac | ||
|
|
99817fd8bd | ||
|
|
3f35fedd9d | ||
|
|
b7c2d13992 | ||
|
|
a6426587fb | ||
|
|
5f68356278 | ||
|
|
08f61e7408 | ||
|
|
9beda15f00 | ||
|
|
09d1663f81 | ||
|
|
f43b6e7cff | ||
|
|
6581eb4fd1 | ||
|
|
caedfe5c1e | ||
|
|
f6a234aaf9 | ||
|
|
8450fb6596 | ||
|
|
41fa3b5a8c | ||
|
|
522de45a62 | ||
|
|
4acc4b2dda | ||
|
|
c1f4855daf | ||
|
|
3506e96cfd | ||
|
|
b42a94ed77 | ||
|
|
80da790a3c | ||
|
|
d6c706d0f9 | ||
|
|
bd09a6ac45 | ||
|
|
4ada31b20e | ||
|
|
5d8b55e96b | ||
|
|
eab4893aa2 | ||
|
|
4806499040 | ||
|
|
1b1c02ceaa | ||
|
|
a8f0a8a253 | ||
|
|
b68115d3b2 | ||
|
|
c353d60ddc | ||
|
|
5d1337f15d | ||
|
|
eeee8af806 | ||
|
|
61b9aca746 | ||
|
|
c4358b91a2 | ||
|
|
4ce9e67460 | ||
|
|
8e25598a78 | ||
|
|
e88e6a7bcd | ||
|
|
971dfc4c14 | ||
|
|
9d9bf84c3f | ||
|
|
f7e170865d | ||
|
|
134441a6e7 | ||
|
|
80a5d21576 | ||
|
|
45c84beb81 | ||
|
|
4a4ee19ba9 | ||
|
|
f1a812dae2 | ||
|
|
a4781d5882 | ||
|
|
0dba28c388 | ||
|
|
67f7e6a6d2 | ||
|
|
7668be50ae | ||
|
|
100664f77e | ||
|
|
e54f837b83 | ||
|
|
a33337ae0c | ||
|
|
13aa456c3c | ||
|
|
65a194a6d2 | ||
|
|
55e3dd3c18 | ||
|
|
b88f496f4e | ||
|
|
696cf8490b | ||
|
|
ffc08227dd | ||
|
|
6cb3a2de43 | ||
|
|
47543e46f2 | ||
|
|
abd7b4f0f4 | ||
|
|
8caf9f110b | ||
|
|
1370b53726 | ||
|
|
2435436580 | ||
|
|
22aa8ee334 | ||
|
|
6d688ed2bc | ||
|
|
46e204165d | ||
|
|
8590a9a110 | ||
|
|
62058d5dfe | ||
|
|
9bfb965e63 | ||
|
|
1d397af674 | ||
|
|
141a468808 | ||
|
|
47bfef958b | ||
|
|
a24d724e2b | ||
|
|
dd72eacd21 | ||
|
|
e27dc1f7a2 | ||
|
|
ea847d7d32 | ||
|
|
87be0ef073 | ||
|
|
c3c3fc6720 | ||
|
|
8bdb9a657f | ||
|
|
71e1ada641 | ||
|
|
37a3b72025 | ||
|
|
c49573db65 | ||
|
|
97c3e6089d | ||
|
|
8586ced3cc | ||
|
|
2b44ede92b | ||
|
|
86a12e2d28 | ||
|
|
9d7509b4dd | ||
|
|
defece95fc | ||
|
|
7b78d99ac4 | ||
|
|
dbb45e7253 | ||
|
|
7286d3b94f | ||
|
|
cc802b1e9f | ||
|
|
a66a2f5645 | ||
|
|
885c0dc500 | ||
|
|
b826b53cee | ||
|
|
ea93162056 | ||
|
|
39afeab5f8 | ||
|
|
b6950332ad | ||
|
|
ccdce37f88 | ||
|
|
9d6a7f709c | ||
|
|
96a4125f15 | ||
|
|
b16e90c10d | ||
|
|
5164315243 | ||
|
|
3df0492275 | ||
|
|
fa8ca0569d | ||
|
|
f78354a377 | ||
|
|
8b1d8e6a90 | ||
|
|
2244fb0523 | ||
|
|
c611b39f52 | ||
|
|
73a0255ae8 | ||
|
|
e978541bf1 | ||
|
|
ae77c60c53 | ||
|
|
b0d9a934d9 | ||
|
|
1583c86c78 | ||
|
|
a779ba5c0e | ||
|
|
be46ed2686 | ||
|
|
e5002b4ef3 | ||
|
|
1372681b87 | ||
|
|
2179ce6fff | ||
|
|
28dbaf317e | ||
|
|
753db25e4c | ||
|
|
65719b560b | ||
|
|
44402ac6e1 | ||
|
|
7e1b26c6a9 | ||
|
|
8550c92e37 | ||
|
|
7d3b2c064a | ||
|
|
2a715d5348 | ||
|
|
9b76d62753 | ||
|
|
80898407c3 | ||
|
|
40208a13e0 | ||
|
|
ae9b7183b5 | ||
|
|
90569192b7 | ||
|
|
223150550f | ||
|
|
80c6f600db | ||
|
|
a938be3712 | ||
|
|
31c6b0664f | ||
|
|
3fa4052538 | ||
|
|
1780f3858d | ||
|
|
f2de0dc673 | ||
|
|
5eb85fd99c | ||
|
|
3f59597864 | ||
|
|
2ba05a46ee | ||
|
|
292ed87201 | ||
|
|
7e686a8882 | ||
|
|
4ca6e9ec3a | ||
|
|
bd5f95b1c5 | ||
|
|
167dfc3847 | ||
|
|
dcd441f15e | ||
|
|
90c8fbeee4 | ||
|
|
cae1840506 | ||
|
|
1860057a25 | ||
|
|
82c6f6f6bc | ||
|
|
2545262fc2 | ||
|
|
24dd535d9e | ||
|
|
b27fe871c4 | ||
|
|
cb5fff0310 | ||
|
|
b60d45ba76 | ||
|
|
7336a3d9c7 | ||
|
|
c7829201e1 | ||
|
|
63b6c76f51 | ||
|
|
056a5df4e1 | ||
|
|
757d1add5b | ||
|
|
3cf3feb2ae | ||
|
|
46579900cd | ||
|
|
2d4a5a30f7 | ||
|
|
2ed5a045de | ||
|
|
4b42496273 | ||
|
|
c5d8a446ae | ||
|
|
9bee62ada9 | ||
|
|
d15caba4a6 | ||
|
|
536c8128dd | ||
|
|
420359066e | ||
|
|
99918ee0c0 | ||
|
|
480e9ea3ec | ||
|
|
2ee4029e04 | ||
|
|
edd186bdfe | ||
|
|
864d40d934 | ||
|
|
856752db21 | ||
|
|
7fde6db9d1 | ||
|
|
0aac046a0e | ||
|
|
1ad11adb94 | ||
|
|
505adea0ef | ||
|
|
9c64721bf0 | ||
|
|
965fdad8b1 | ||
|
|
a6d6589811 | ||
|
|
a227af05d9 | ||
|
|
3b7a16c439 | ||
|
|
ce0898956d | ||
|
|
cd72851289 | ||
|
|
6711d752e2 | ||
|
|
65472bed54 | ||
|
|
a188ad68ed | ||
|
|
bb01afeb99 | ||
|
|
a1be3a3e77 | ||
|
|
b5e7877912 | ||
|
|
587e7118c9 | ||
|
|
8c5ffb88a3 | ||
|
|
2a00f41ef8 | ||
|
|
f676f94278 | ||
|
|
4ca762344c | ||
|
|
5c5ab5bad5 | ||
|
|
cd6e40f01c | ||
|
|
59a8a22e8a | ||
|
|
2910164d5a | ||
|
|
3afc89db6b | ||
|
|
bfc45993f8 | ||
|
|
b95e2b365a | ||
|
|
a8d50223aa | ||
|
|
73e788b513 | ||
|
|
15d135c11b | ||
|
|
0fcda0be1a | ||
|
|
1bbcd638b8 | ||
|
|
fe73750d66 | ||
|
|
ca4c576f78 | ||
|
|
648637a1a1 | ||
|
|
91adf379da | ||
|
|
08ca57cba2 | ||
|
|
e6fcb1272a | ||
|
|
60a5acdb9d | ||
|
|
2221261ec2 | ||
|
|
6627514e88 | ||
|
|
d7b02da719 | ||
|
|
ebbe015bbc | ||
|
|
e03a92f8d8 | ||
|
|
7d98c1b355 | ||
|
|
e78040a376 | ||
|
|
97eedb568c | ||
|
|
161f1db40e | ||
|
|
b50b97a4d1 | ||
|
|
e8e176ed40 | ||
|
|
bc82e9c7fa | ||
|
|
73ace77305 | ||
|
|
4023dcf09d | ||
|
|
ebf236b387 | ||
|
|
e40d77420c | ||
|
|
27b9f57d7a | ||
|
|
3cd9639f34 | ||
|
|
a5a14f6c76 | ||
|
|
bfac3f8ad0 | ||
|
|
dcadbac4d5 | ||
|
|
626d703d77 | ||
|
|
25b7ef3d2e | ||
|
|
d812e6ab96 | ||
|
|
74e4a77ce6 | ||
|
|
863e1ee113 | ||
|
|
33e4a15830 | ||
|
|
bf09786423 | ||
|
|
f7070dd3f7 | ||
|
|
523c650c9d | ||
|
|
469d408b09 | ||
|
|
cb1b13bdb4 | ||
|
|
480a34c0f7 | ||
|
|
93479320ee | ||
|
|
b2acd5511c | ||
|
|
96ed2f2119 | ||
|
|
606e0120ee | ||
|
|
6da85fd286 | ||
|
|
298b960ef7 | ||
|
|
ee3fbabece | ||
|
|
0bed5cd99a | ||
|
|
bc55af27a7 | ||
|
|
a0006f73ac | ||
|
|
f79070fe60 | ||
|
|
b238f3d060 | ||
|
|
a6ad86bd59 | ||
|
|
60e2ef7cda | ||
|
|
cde0ba11fa | ||
|
|
da9fcd1087 | ||
|
|
8929b37dbe | ||
|
|
509604833e | ||
|
|
08ac9680d7 | ||
|
|
ca5404a93b | ||
|
|
f6f4547af3 | ||
|
|
669f8b0431 | ||
|
|
86aa0251ab | ||
|
|
2252048d2e | ||
|
|
53571a7d72 | ||
|
|
0a469f4ccf | ||
|
|
d10ed664bf | ||
|
|
4aad8d36a9 | ||
|
|
3e9295f313 | ||
|
|
6aa66e99b5 | ||
|
|
c38ad89cd7 | ||
|
|
8fdcc5dd50 | ||
|
|
364381f017 | ||
|
|
9433aa503b | ||
|
|
82dee95cd0 | ||
|
|
813db4a985 | ||
|
|
c63bc28ca0 | ||
|
|
29e74a2c9e | ||
|
|
80fdc6005b | ||
|
|
9e25aa1f9f | ||
|
|
f58d5d28cf | ||
|
|
81cb0d43d7 | ||
|
|
9d7052c626 | ||
|
|
4edd0e0ab7 | ||
|
|
a3d60d393b | ||
|
|
311ab67ebe | ||
|
|
1f581e7b51 | ||
|
|
5fe934110e | ||
|
|
f4df8873be | ||
|
|
6f4c5d7195 | ||
|
|
06f1c2fba2 | ||
|
|
36b32a1813 | ||
|
|
d3a43cb65f | ||
|
|
d98e7b9416 | ||
|
|
fc284fd0a2 | ||
|
|
5841d2eb66 | ||
|
|
0c154be04e | ||
|
|
90bc0483ae | ||
|
|
32765b2d34 | ||
|
|
d9e80ebef9 | ||
|
|
445102226e | ||
|
|
a6ce882511 | ||
|
|
9d20c4c4a9 | ||
|
|
e2d8ea0a70 | ||
|
|
b33d003ba5 | ||
|
|
ee07a90b5e | ||
|
|
55f79507fe | ||
|
|
70d2f1e3d9 | ||
|
|
acafc072db | ||
|
|
51e40581b0 | ||
|
|
a372cf0178 | ||
|
|
d863aa7aa6 | ||
|
|
b31e54b3e5 | ||
|
|
9b5734f2ff | ||
|
|
6b59b9988c | ||
|
|
f9de546d14 | ||
|
|
9e304b947b | ||
|
|
c11a219c62 | ||
|
|
3cc22575cb | ||
|
|
c42b6e2fdb | ||
|
|
1e5dd1cc53 | ||
|
|
877532559e | ||
|
|
cd4750fcce | ||
|
|
fc2be71e1f | ||
|
|
0c9aa2f681 | ||
|
|
1883be95d5 | ||
|
|
f7dadda52a | ||
|
|
2a8fd24504 | ||
|
|
71c70a1b36 | ||
|
|
c34379d936 | ||
|
|
2dde29c628 | ||
|
|
818e71d49c | ||
|
|
6bbeb5ef87 | ||
|
|
7ebed70316 | ||
|
|
130237fc87 | ||
|
|
a28774c2c4 | ||
|
|
b9ade5d2a3 | ||
|
|
b677aa1715 | ||
|
|
7a036883e8 | ||
|
|
e665df21da | ||
|
|
d066b9c913 | ||
|
|
b4290c24b3 | ||
|
|
b66656ad84 | ||
|
|
5c032e84be | ||
|
|
83437ae4ba | ||
|
|
24434cc61a | ||
|
|
53dc40e8c7 | ||
|
|
4affb2bc5b | ||
|
|
3d7b057026 | ||
|
|
d36ab337d7 | ||
|
|
c87690f378 | ||
|
|
9fb9fd4568 | ||
|
|
6bd4fd91ff | ||
|
|
164c2463f5 | ||
|
|
73532e41c5 | ||
|
|
3392b1a1ca | ||
|
|
08cc7114ac | ||
|
|
012f9b5314 | ||
|
|
ba6069324f | ||
|
|
0d26d4cdbd | ||
|
|
4b920feffa | ||
|
|
8e038f6944 | ||
|
|
ce94255a9e | ||
|
|
b4b63f86d9 | ||
|
|
830373efb3 | ||
|
|
c3f18671ec | ||
|
|
2901fb0d72 | ||
|
|
c5466edf71 | ||
|
|
bfc5bfe973 | ||
|
|
4da11955f1 | ||
|
|
d4c775a537 | ||
|
|
ef95a8a305 | ||
|
|
a173d2c808 | ||
|
|
ee002df110 | ||
|
|
06ef17048a | ||
|
|
9487348ba8 | ||
|
|
757060b11f | ||
|
|
ab1f8437ea | ||
|
|
d7afd31572 | ||
|
|
cd3178224a | ||
|
|
9193a1a5d6 | ||
|
|
0d33758ba4 | ||
|
|
e7e8c397ef | ||
|
|
0f3e36a447 | ||
|
|
333dbba393 | ||
|
|
b04b12c7a0 | ||
|
|
1dc804a2b9 | ||
|
|
75219d457a | ||
|
|
a1d69b3210 | ||
|
|
dcbc2f1145 | ||
|
|
36903b338a | ||
|
|
9d8d6832af |
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "Hoppscotch",
|
||||
"image": "mcr.microsoft.com/devcontainers/typescript-node:18",
|
||||
"name": "Hoppscotch",
|
||||
"image": "mcr.microsoft.com/devcontainers/typescript-node:18",
|
||||
"forwardPorts": [3000],
|
||||
"features": {
|
||||
"ghcr.io/NicoVIII/devcontainer-features/pnpm:1": {}
|
||||
},
|
||||
"postCreateCommand": "cp packages/hoppscotch-app/.env.example packages/hoppscotch-app/.env && pnpm i"
|
||||
"features": {
|
||||
"ghcr.io/NicoVIII/devcontainer-features/pnpm:1": {}
|
||||
},
|
||||
"postCreateCommand": "cp .env.example .env && pnpm i"
|
||||
}
|
||||
|
||||
106
.dockerignore
106
.dockerignore
@@ -1,104 +1,2 @@
|
||||
Dockerfile
|
||||
.vscode
|
||||
.github
|
||||
|
||||
# Created by .ignore support plugin (hsz.mobi)
|
||||
|
||||
# Firebase
|
||||
.firebase
|
||||
|
||||
### Node template
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# TypeScript v1 declaration files
|
||||
typings/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
|
||||
# next.js build output
|
||||
.next
|
||||
|
||||
# nuxt.js build output
|
||||
.nuxt
|
||||
|
||||
# Nuxt generate
|
||||
dist
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# Serverless directories
|
||||
.serverless
|
||||
|
||||
# IDE / Editor
|
||||
.idea
|
||||
|
||||
# Service worker
|
||||
sw.*
|
||||
|
||||
# Mac OSX
|
||||
.DS_Store
|
||||
|
||||
# Vim swap files
|
||||
*.swp
|
||||
|
||||
# Build data
|
||||
.hoppscotch
|
||||
|
||||
# File explorer
|
||||
.directory
|
||||
node_modules
|
||||
**/*/node_modules
|
||||
|
||||
94
.env.example
94
.env.example
@@ -1,31 +1,79 @@
|
||||
# Google Analytics ID
|
||||
VITE_GA_ID=UA-61422507-4
|
||||
#-----------------------Backend Config------------------------------#
|
||||
# Prisma Config
|
||||
DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch
|
||||
|
||||
# Google Tag Manager ID
|
||||
VITE_GTM_ID=GTM-NMKVBMV
|
||||
# Auth Tokens Config
|
||||
JWT_SECRET="secret1233"
|
||||
TOKEN_SALT_COMPLEXITY=10
|
||||
MAGIC_LINK_TOKEN_VALIDITY= 3
|
||||
REFRESH_TOKEN_VALIDITY="604800000" # Default validity is 7 days (604800000 ms) in ms
|
||||
ACCESS_TOKEN_VALIDITY="86400000" # Default validity is 1 day (86400000 ms) in ms
|
||||
SESSION_SECRET='add some secret here'
|
||||
# Reccomended to be true, set to false if you are using http
|
||||
# Note: Some auth providers may not support http requests
|
||||
ALLOW_SECURE_COOKIES=true
|
||||
|
||||
# Hoppscotch App Domain Config
|
||||
REDIRECT_URL="http://localhost:3000"
|
||||
WHITELISTED_ORIGINS="http://localhost:3170,http://localhost:3000,http://localhost:3100"
|
||||
VITE_ALLOWED_AUTH_PROVIDERS=GOOGLE,GITHUB,MICROSOFT,EMAIL
|
||||
|
||||
# Google Auth Config
|
||||
GOOGLE_CLIENT_ID="************************************************"
|
||||
GOOGLE_CLIENT_SECRET="************************************************"
|
||||
GOOGLE_CALLBACK_URL="http://localhost:3170/v1/auth/google/callback"
|
||||
GOOGLE_SCOPE="email,profile"
|
||||
|
||||
# Github Auth Config
|
||||
GITHUB_CLIENT_ID="************************************************"
|
||||
GITHUB_CLIENT_SECRET="************************************************"
|
||||
GITHUB_CALLBACK_URL="http://localhost:3170/v1/auth/github/callback"
|
||||
GITHUB_SCOPE="user:email"
|
||||
|
||||
# Microsoft Auth Config
|
||||
MICROSOFT_CLIENT_ID="************************************************"
|
||||
MICROSOFT_CLIENT_SECRET="************************************************"
|
||||
MICROSOFT_CALLBACK_URL="http://localhost:3170/v1/auth/microsoft/callback"
|
||||
MICROSOFT_SCOPE="user.read"
|
||||
MICROSOFT_TENANT="common"
|
||||
|
||||
# Mailer config
|
||||
MAILER_SMTP_ENABLE="true"
|
||||
MAILER_USE_CUSTOM_CONFIGS="false"
|
||||
MAILER_ADDRESS_FROM='"From Name Here" <from@example.com>'
|
||||
|
||||
MAILER_SMTP_URL="smtps://user@domain.com:pass@smtp.domain.com" # used if custom mailer configs is false
|
||||
|
||||
# The following are used if custom mailer configs is true
|
||||
MAILER_SMTP_HOST="smtp.domain.com"
|
||||
MAILER_SMTP_PORT="587"
|
||||
MAILER_SMTP_SECURE="true"
|
||||
MAILER_SMTP_USER="user@domain.com"
|
||||
MAILER_SMTP_PASSWORD="pass"
|
||||
MAILER_TLS_REJECT_UNAUTHORIZED="true"
|
||||
|
||||
# Rate Limit Config
|
||||
RATE_LIMIT_TTL=60 # In seconds
|
||||
RATE_LIMIT_MAX=100 # Max requests per IP
|
||||
|
||||
|
||||
#-----------------------Frontend Config------------------------------#
|
||||
|
||||
# Firebase config
|
||||
VITE_API_KEY=AIzaSyCMsFreESs58-hRxTtiqQrIcimh4i1wbsM
|
||||
VITE_AUTH_DOMAIN=postwoman-api.firebaseapp.com
|
||||
VITE_DATABASE_URL=https://postwoman-api.firebaseio.com
|
||||
VITE_PROJECT_ID=postwoman-api
|
||||
VITE_STORAGE_BUCKET=postwoman-api.appspot.com
|
||||
VITE_MESSAGING_SENDER_ID=421993993223
|
||||
VITE_APP_ID=1:421993993223:web:ec0baa8ee8c02ffa1fc6a2
|
||||
VITE_MEASUREMENT_ID=G-BBJ3R80PJT
|
||||
|
||||
# Base URLs
|
||||
VITE_BASE_URL=https://hoppscotch.io
|
||||
VITE_SHORTCODE_BASE_URL=https://hopp.sh
|
||||
VITE_BACKEND_LOGIN_API_URL=http://localhost:5444
|
||||
VITE_BASE_URL=http://localhost:3000
|
||||
VITE_SHORTCODE_BASE_URL=http://localhost:3000
|
||||
VITE_ADMIN_URL=http://localhost:3100
|
||||
|
||||
# Backend URLs
|
||||
VITE_BACKEND_GQL_URL=https://api.hoppscotch.io/graphql
|
||||
VITE_BACKEND_WS_URL=wss://api.hoppscotch.io/graphql
|
||||
VITE_BACKEND_GQL_URL=http://localhost:3170/graphql
|
||||
VITE_BACKEND_WS_URL=ws://localhost:3170/graphql
|
||||
VITE_BACKEND_API_URL=http://localhost:3170/v1
|
||||
|
||||
# Sentry (Optional)
|
||||
# VITE_SENTRY_DSN: <Sentry DSN here>
|
||||
# VITE_SENTRY_ENVIRONMENT: <Sentry environment value here>
|
||||
# VITE_SENTRY_RELEASE_TAG: <Sentry release tag here (for release monitoring)>
|
||||
# Terms Of Service And Privacy Policy Links (Optional)
|
||||
VITE_APP_TOS_LINK=https://docs.hoppscotch.io/support/terms
|
||||
VITE_APP_PRIVACY_POLICY_LINK=https://docs.hoppscotch.io/support/privacy
|
||||
|
||||
# Proxyscotch Access Token (Optional)
|
||||
# VITE_PROXYSCOTCH_ACCESS_TOKEN: <Token Set In Proxyscotch Server>
|
||||
# Set to `true` for subpath based access
|
||||
ENABLE_SUBPATH_BASED_ACCESS=false
|
||||
|
||||
17
.github/pull_request_template.md
vendored
17
.github/pull_request_template.md
vendored
@@ -7,20 +7,15 @@ Please make sure that the pull request is limited to one type (docs, feature, et
|
||||
<!-- If this pull request closes an issue, please mention the issue number below -->
|
||||
Closes # <!-- Issue # here -->
|
||||
|
||||
### Description
|
||||
<!-- Add a brief description of the pull request -->
|
||||
<!-- Add an introduction into what this PR tries to solve in a couple of sentences -->
|
||||
|
||||
### What's changed
|
||||
<!-- Describe point by point the different things you have changed in this PR -->
|
||||
|
||||
<!-- You can also choose to add a list of changes and if they have been completed or not by using the markdown to-do list syntax
|
||||
- [ ] Not Completed
|
||||
- [x] Completed
|
||||
-->
|
||||
|
||||
### Checks
|
||||
<!-- Make sure your pull request passes the CI checks and do check the following fields as needed - -->
|
||||
- [ ] My pull request adheres to the code style of this project
|
||||
- [ ] My code requires changes to the documentation
|
||||
- [ ] I have updated the documentation as required
|
||||
- [ ] All the tests have passed
|
||||
|
||||
### Additional Information
|
||||
<!-- Any additional information like breaking changes, dependencies added, screenshots, comparisons between new and old behaviour, etc. -->
|
||||
### Notes to reviewers
|
||||
<!-- Any information you feel the reviewer should know about when reviewing your PR -->
|
||||
|
||||
93
.github/workflows/codeql-analysis.yml
vendored
93
.github/workflows/codeql-analysis.yml
vendored
@@ -1,72 +1,63 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL"
|
||||
name: "CodeQL analysis"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches: [main]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ main ]
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '39 7 * * 2'
|
||||
# ┌───────────── minute (0 - 59)
|
||||
# │ ┌───────────── hour (0 - 23)
|
||||
# │ │ ┌───────────── day of the month (1 - 31)
|
||||
# │ │ │ ┌───────────── month (1 - 12 or JAN-DEC)
|
||||
# │ │ │ │ ┌───────────── day of the week (0 - 6 or SUN-SAT)
|
||||
# │ │ │ │ │
|
||||
# │ │ │ │ │
|
||||
# │ │ │ │ │
|
||||
# * * * * *
|
||||
- cron: '30 1 * * 0'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
# CodeQL runs on ubuntu-latest, windows-latest, and macos-latest
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
# required for all workflows
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'javascript' ]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||
# Learn more about CodeQL language support at https://git.io/codeql-language-support
|
||||
# only required for workflows in private repositories
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
# Run extended queries including queries using machine learning
|
||||
queries: security-extended
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
# Run extended queries including queries using machine learning
|
||||
queries: security-extended
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below).
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||
# and modify them (or add more) to build your code if your project
|
||||
# uses a compiled language
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following
|
||||
# three lines and modify them (or add more) to build your code if your
|
||||
# project uses a compiled language
|
||||
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
|
||||
48
.github/workflows/deploy-netlify.yml
vendored
48
.github/workflows/deploy-netlify.yml
vendored
@@ -1,48 +0,0 @@
|
||||
name: Deploy to Netlify
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Push build files to Netlify
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Environment
|
||||
run: mv packages/hoppscotch-web/.env.example packages/hoppscotch-web/.env
|
||||
|
||||
- name: Setup and run pnpm install
|
||||
uses: pnpm/action-setup@v2.2.2
|
||||
with:
|
||||
version: 7
|
||||
run_install: true
|
||||
|
||||
- name: Build Site
|
||||
env:
|
||||
VITE_SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
VITE_SENTRY_ENVIRONMENT: production
|
||||
VITE_SENTRY_RELEASE_TAG: ${{ github.sha }}
|
||||
run: pnpm run generate
|
||||
|
||||
# Deploy the production site with netlify-cli
|
||||
- name: Deploy to Netlify (production)
|
||||
run: npx netlify-cli deploy --dir=packages/hoppscotch-web/dist --prod
|
||||
env:
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_PRODUCTION_SITE_ID }}
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
|
||||
- name: Create Sentry Release
|
||||
uses: getsentry/action-release@v1
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
with:
|
||||
environment: production
|
||||
ignore_missing: true
|
||||
ignore_empty: true
|
||||
version: ${{ github.sha }}
|
||||
60
.github/workflows/deploy-preview-netlify.yml
vendored
60
.github/workflows/deploy-preview-netlify.yml
vendored
@@ -1,60 +0,0 @@
|
||||
name: Deploy to Preview Netlify
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Push build files to Netlify
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup and run pnpm install
|
||||
uses: pnpm/action-setup@v2.2.2
|
||||
env:
|
||||
VITE_BACKEND_GQL_URL: ${{ secrets.STAGING_BACKEND_GQL_URL }}
|
||||
with:
|
||||
version: 7
|
||||
run_install: true
|
||||
|
||||
- name: Build Site
|
||||
env:
|
||||
VITE_GA_ID: ${{ secrets.STAGING_GA_ID }}
|
||||
VITE_GTM_ID: ${{ secrets.STAGING_GTM_ID }}
|
||||
VITE_API_KEY: ${{ secrets.STAGING_FB_API_KEY }}
|
||||
VITE_AUTH_DOMAIN: ${{ secrets.STAGING_FB_AUTH_DOMAIN }}
|
||||
VITE_DATABASE_URL: ${{ secrets.STAGING_FB_DATABASE_URL }}
|
||||
VITE_PROJECT_ID: ${{ secrets.STAGING_FB_PROJECT_ID }}
|
||||
VITE_STORAGE_BUCKET: ${{ secrets.STAGING_FB_STORAGE_BUCKET }}
|
||||
VITE_MESSAGING_SENDER_ID: ${{ secrets.STAGING_FB_MESSAGING_SENDER_ID }}
|
||||
VITE_APP_ID: ${{ secrets.STAGING_FB_APP_ID }}
|
||||
VITE_BASE_URL: ${{ secrets.STAGING_BASE_URL }}
|
||||
VITE_BACKEND_GQL_URL: ${{ secrets.STAGING_BACKEND_GQL_URL }}
|
||||
VITE_BACKEND_WS_URL: ${{ secrets.STAGING_BACKEND_WS_URL }}
|
||||
VITE_SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
VITE_SENTRY_RELEASE_TAG: ${{ github.sha }}
|
||||
VITE_SENTRY_ENVIRONMENT: staging
|
||||
run: pnpm run generate
|
||||
|
||||
# Deploy the preview site with netlify-cli
|
||||
- name: Deploy to Netlify (preview)
|
||||
run: npx netlify-cli deploy --dir=packages/hoppscotch-web/dist --alias=preview
|
||||
env:
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_STAGING_SITE_ID }}
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
|
||||
- name: Create Sentry Release
|
||||
uses: getsentry/action-release@v1
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
with:
|
||||
environment: preview
|
||||
ignore_missing: true
|
||||
ignore_empty: true
|
||||
version: ${{ github.sha }}
|
||||
21
.github/workflows/deploy-prod.yml
vendored
21
.github/workflows/deploy-prod.yml
vendored
@@ -1,21 +0,0 @@
|
||||
name: Deploy to Live Channel
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy_live_website:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Deploy to Firebase (production)
|
||||
uses: FirebaseExtended/action-hosting-deploy@v0
|
||||
with:
|
||||
repoToken: '${{ secrets.GITHUB_TOKEN }}'
|
||||
firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT_POSTWOMAN_API }}'
|
||||
channelId: live
|
||||
projectId: postwoman-api
|
||||
60
.github/workflows/deploy-staging-netlify.yml
vendored
60
.github/workflows/deploy-staging-netlify.yml
vendored
@@ -1,60 +0,0 @@
|
||||
name: Deploy to Staging Netlify
|
||||
|
||||
on:
|
||||
push:
|
||||
# TODO: Migrate to staging branch only
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Push build files to Netlify
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup and run pnpm install
|
||||
uses: pnpm/action-setup@v2.2.2
|
||||
env:
|
||||
VITE_BACKEND_GQL_URL: ${{ secrets.STAGING_BACKEND_GQL_URL }}
|
||||
with:
|
||||
version: 7
|
||||
run_install: true
|
||||
|
||||
- name: Build Site
|
||||
env:
|
||||
VITE_GA_ID: ${{ secrets.STAGING_GA_ID }}
|
||||
VITE_GTM_ID: ${{ secrets.STAGING_GTM_ID }}
|
||||
VITE_API_KEY: ${{ secrets.STAGING_FB_API_KEY }}
|
||||
VITE_AUTH_DOMAIN: ${{ secrets.STAGING_FB_AUTH_DOMAIN }}
|
||||
VITE_DATABASE_URL: ${{ secrets.STAGING_FB_DATABASE_URL }}
|
||||
VITE_PROJECT_ID: ${{ secrets.STAGING_FB_PROJECT_ID }}
|
||||
VITE_STORAGE_BUCKET: ${{ secrets.STAGING_FB_STORAGE_BUCKET }}
|
||||
VITE_MESSAGING_SENDER_ID: ${{ secrets.STAGING_FB_MESSAGING_SENDER_ID }}
|
||||
VITE_APP_ID: ${{ secrets.STAGING_FB_APP_ID }}
|
||||
VITE_BASE_URL: ${{ secrets.STAGING_BASE_URL }}
|
||||
VITE_BACKEND_GQL_URL: ${{ secrets.STAGING_BACKEND_GQL_URL }}
|
||||
VITE_BACKEND_WS_URL: ${{ secrets.STAGING_BACKEND_WS_URL }}
|
||||
VITE_SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
VITE_SENTRY_RELEASE_TAG: ${{ github.sha }}
|
||||
VITE_SENTRY_ENVIRONMENT: staging
|
||||
run: pnpm run generate
|
||||
|
||||
# Deploy the staging site with netlify-cli
|
||||
- name: Deploy to Netlify (staging)
|
||||
run: npx netlify-cli deploy --dir=packages/hoppscotch-web/dist --prod
|
||||
env:
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_STAGING_SITE_ID }}
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
|
||||
- name: Create Sentry Release
|
||||
uses: getsentry/action-release@v1
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
with:
|
||||
environment: staging
|
||||
ignore_missing: true
|
||||
ignore_empty: true
|
||||
version: ${{ github.sha }}
|
||||
46
.github/workflows/publish-docker.yml
vendored
46
.github/workflows/publish-docker.yml
vendored
@@ -1,46 +0,0 @@
|
||||
name: Publish Docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
push_to_registry:
|
||||
name: Push Docker image to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: hoppscotch/hoppscotch
|
||||
flavor: |
|
||||
latest=true
|
||||
prefix=
|
||||
suffix=
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64/v8,linux/arm/v7
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
84
.github/workflows/release-push-docker.yml
vendored
Normal file
84
.github/workflows/release-push-docker.yml
vendored
Normal file
@@ -0,0 +1,84 @@
|
||||
name: "Push containers to Docker Hub on release"
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*.*.*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup environment
|
||||
run: cp .env.example .env
|
||||
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Build and push the backend container
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./prod.Dockerfile
|
||||
target: backend
|
||||
push: true
|
||||
platforms: |
|
||||
linux/amd64
|
||||
linux/arm64
|
||||
tags: |
|
||||
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_BACKEND_CONTAINER_NAME }}:latest
|
||||
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_BACKEND_CONTAINER_NAME }}:${{ github.ref_name }}
|
||||
|
||||
- name: Build and push the frontend container
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./prod.Dockerfile
|
||||
target: app
|
||||
push: true
|
||||
platforms: |
|
||||
linux/amd64
|
||||
linux/arm64
|
||||
tags: |
|
||||
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_FRONTEND_CONTAINER_NAME }}:latest
|
||||
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_FRONTEND_CONTAINER_NAME }}:${{ github.ref_name }}
|
||||
|
||||
- name: Build and push the admin dashboard container
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./prod.Dockerfile
|
||||
target: sh_admin
|
||||
push: true
|
||||
platforms: |
|
||||
linux/amd64
|
||||
linux/arm64
|
||||
tags: |
|
||||
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_SH_ADMIN_CONTAINER_NAME }}:latest
|
||||
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_SH_ADMIN_CONTAINER_NAME }}:${{ github.ref_name }}
|
||||
|
||||
- name: Build and push the AIO container
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./prod.Dockerfile
|
||||
target: aio
|
||||
push: true
|
||||
platforms: |
|
||||
linux/amd64
|
||||
linux/arm64
|
||||
tags: |
|
||||
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_AIO_CONTAINER_NAME }}:latest
|
||||
${{ secrets.DOCKER_ORG_NAME }}/${{ secrets.DOCKER_AIO_CONTAINER_NAME }}:${{ github.ref_name }}
|
||||
32
.github/workflows/tests.yml
vendored
32
.github/workflows/tests.yml
vendored
@@ -2,12 +2,13 @@ name: Node.js CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, staging, "release/**"]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, staging, "release/**"]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
@@ -15,23 +16,22 @@ jobs:
|
||||
node-version: ["lts/*"]
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v3
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Environment
|
||||
run: mv packages/hoppscotch-app/.env.example packages/hoppscotch-app/.env
|
||||
- name: Setup environment
|
||||
run: mv .env.example .env
|
||||
|
||||
- name: Setup and run pnpm install
|
||||
uses: pnpm/action-setup@v2.2.2
|
||||
with:
|
||||
version: 7
|
||||
run_install: true
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v3
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: pnpm
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 8
|
||||
run_install: true
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm test
|
||||
|
||||
42
.github/workflows/ui.yml
vendored
Normal file
42
.github/workflows/ui.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
name: Deploy to Netlify (ui)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# run this workflow only if an update is made to the ui package
|
||||
paths:
|
||||
- "packages/hoppscotch-ui/**"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup environment
|
||||
run: mv .env.example .env
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2.2.4
|
||||
with:
|
||||
version: 8
|
||||
run_install: true
|
||||
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: pnpm
|
||||
|
||||
- name: Build site
|
||||
run: pnpm run generate-ui
|
||||
|
||||
# Deploy the ui site with netlify-cli
|
||||
- name: Deploy to Netlify (ui)
|
||||
run: npx netlify-cli@15.11.0 deploy --dir=packages/hoppscotch-ui/.histoire/dist --prod
|
||||
env:
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_UI_SITE_ID }}
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -81,10 +81,7 @@ web_modules/
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
.env.*
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
@@ -171,3 +168,6 @@ tests/*/videos
|
||||
|
||||
# PNPM
|
||||
.pnpm-store
|
||||
|
||||
# GQL SDL generated for the frontends
|
||||
gql-gen/
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
module.exports = {
|
||||
semi: false
|
||||
semi: false,
|
||||
trailingComma: "es5",
|
||||
singleQuote: false,
|
||||
printWidth: 80,
|
||||
useTabs: false,
|
||||
tabWidth: 2
|
||||
}
|
||||
|
||||
14
.vscode/extensions.json
vendored
14
.vscode/extensions.json
vendored
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"antfu.iconify",
|
||||
"vue.volar",
|
||||
"esbenp.prettier-vscode",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"editorconfig.editorconfig",
|
||||
"csstools.postcss",
|
||||
"folke.vscode-monorepo-workspace"
|
||||
],
|
||||
"unwantedRecommendations": [
|
||||
"octref.vetur"
|
||||
]
|
||||
}
|
||||
21
CODEOWNERS
Normal file
21
CODEOWNERS
Normal file
@@ -0,0 +1,21 @@
|
||||
# CODEOWNERS is prioritized from bottom to top
|
||||
|
||||
# Packages
|
||||
/packages/codemirror-lang-graphql/ @AndrewBastin
|
||||
/packages/hoppscotch-cli/ @jamesgeorge007
|
||||
/packages/hoppscotch-data/ @AndrewBastin
|
||||
/packages/hoppscotch-js-sandbox/ @jamesgeorge007
|
||||
/packages/hoppscotch-selfhost-web/ @jamesgeorge007
|
||||
/packages/hoppscotch-selfhost-desktop/ @AndrewBastin
|
||||
/packages/hoppscotch-sh-admin/ @JoelJacobStephen
|
||||
/packages/hoppscotch-backend/ @balub
|
||||
|
||||
# READMEs and other documentation files
|
||||
*.md @liyasthomas
|
||||
|
||||
# Self Host deployment related files
|
||||
*.Dockerfile @balub
|
||||
docker-compose.yml @balub
|
||||
docker-compose.deploy.yml @balub
|
||||
*.Caddyfile @balub
|
||||
.dockerignore @balub
|
||||
@@ -6,8 +6,8 @@ We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
nationality, personal appearance, race, caste, color, religion, or sexual
|
||||
identity and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
@@ -22,17 +22,17 @@ community include:
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
* Focusing on what is best not just for us as individuals, but for the overall
|
||||
community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* The use of sexualized language or imagery, and sexual attention or advances of
|
||||
any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Publishing others' private information, such as a physical or email address,
|
||||
without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
@@ -82,15 +82,15 @@ behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
**Community Impact**: A violation through a single incident or series of
|
||||
actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
like social media. Violating these terms may lead to a temporary or permanent
|
||||
ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
@@ -106,23 +106,27 @@ Violating these terms may lead to a permanent ban.
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
**Consequence**: A permanent ban from any sort of public interaction within the
|
||||
community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
version 2.1, available at
|
||||
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
Community Impact Guidelines were inspired by
|
||||
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
|
||||
[https://www.contributor-covenant.org/translations][translations].
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||
[FAQ]: https://www.contributor-covenant.org/faq
|
||||
[translations]: https://www.contributor-covenant.org/translations
|
||||
|
||||
@@ -11,7 +11,4 @@ Please note we have a code of conduct, please follow it in all your interactions
|
||||
build.
|
||||
2. Update the README.md with details of changes to the interface, this includes new environment
|
||||
variables, exposed ports, useful file locations and container parameters.
|
||||
3. Increase the version numbers in any examples files and the README.md to the new version that this
|
||||
Pull Request would represent. The versioning scheme we use is [SemVer](https://semver.org).
|
||||
4. You may merge the Pull Request once you have the sign-off of two other developers, or if you
|
||||
do not have permission to do that, you may request the second reviewer merge it for you.
|
||||
3. Make sure you do not expose environment variables or other sensitive information in your PR.
|
||||
|
||||
29
Dockerfile
29
Dockerfile
@@ -1,29 +0,0 @@
|
||||
FROM node:lts-alpine
|
||||
|
||||
LABEL maintainer="Hoppscotch (support@hoppscotch.io)"
|
||||
|
||||
# Add git as the prebuild target requires it to parse version information
|
||||
RUN apk add --no-cache --virtual .gyp \
|
||||
python3 \
|
||||
make \
|
||||
g++
|
||||
|
||||
# Create app directory
|
||||
WORKDIR /app
|
||||
|
||||
ADD . /app/
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm install -g pnpm
|
||||
|
||||
RUN mv packages/hoppscotch-app/.env.example packages/hoppscotch-app/.env
|
||||
|
||||
RUN pnpm i --unsafe-perm=true
|
||||
|
||||
ENV HOST 0.0.0.0
|
||||
EXPOSE 3000
|
||||
|
||||
RUN pnpm run generate
|
||||
|
||||
CMD ["pnpm", "run", "start"]
|
||||
228
README.md
228
README.md
@@ -2,23 +2,18 @@
|
||||
<a href="https://hoppscotch.io">
|
||||
<img
|
||||
src="https://avatars.githubusercontent.com/u/56705483"
|
||||
alt="Hoppscotch Logo"
|
||||
alt="Hoppscotch"
|
||||
height="64"
|
||||
/>
|
||||
</a>
|
||||
<br />
|
||||
<p>
|
||||
<h3>
|
||||
<b>
|
||||
Hoppscotch
|
||||
</b>
|
||||
</h3>
|
||||
</p>
|
||||
<p>
|
||||
<h3>
|
||||
<b>
|
||||
Open source API development ecosystem
|
||||
Hoppscotch
|
||||
</b>
|
||||
</p>
|
||||
</h3>
|
||||
<b>
|
||||
Open Source API Development Ecosystem
|
||||
</b>
|
||||
<p>
|
||||
|
||||
[](CODE_OF_CONDUCT.md) [](https://hoppscotch.io) [](https://github.com/hoppscotch/hoppscotch/actions) [](https://twitter.com/share?text=%F0%9F%91%BD%20Hoppscotch%20%E2%80%A2%20Open%20source%20API%20development%20ecosystem%20-%20Helps%20you%20create%20requests%20faster,%20saving%20precious%20time%20on%20development.&url=https://hoppscotch.io&hashtags=hoppscotch&via=hoppscotch_io)
|
||||
@@ -34,23 +29,18 @@
|
||||
</p>
|
||||
<br />
|
||||
<p>
|
||||
<a href="https://hoppscotch.io/#gh-light-mode-only" target="_blank">
|
||||
<img
|
||||
src="./packages/hoppscotch-app/public/images/banner-light.png"
|
||||
alt="Hoppscotch"
|
||||
width="100%"
|
||||
/>
|
||||
</a>
|
||||
<a href="https://hoppscotch.io/#gh-dark-mode-only" target="_blank">
|
||||
<img
|
||||
src="./packages/hoppscotch-app/public/images/banner-dark.png"
|
||||
alt="Hoppscotch"
|
||||
width="100%"
|
||||
/>
|
||||
<a href="https://hoppscotch.io">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/hoppscotch-common/public/images/banner-dark.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/hoppscotch-common/public/images/banner-light.png">
|
||||
<img alt="Hoppscotch" src="./packages/hoppscotch-common/public/images/banner-dark.png">
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
_We highly recommend you take a look at the [**Hoppscotch Documentation**](https://docs.hoppscotch.io) to learn more about the app._
|
||||
|
||||
#### **Support**
|
||||
|
||||
[](https://hoppscotch.io/discord) [](https://hoppscotch.io/telegram) [](https://github.com/hoppscotch/hoppscotch/discussions)
|
||||
@@ -59,9 +49,9 @@
|
||||
|
||||
❤️ **Lightweight:** Crafted with minimalistic UI design.
|
||||
|
||||
⚡️ **Fast:** Send requests and get/copy responses in real-time.
|
||||
⚡️ **Fast:** Send requests and get responses in real time.
|
||||
|
||||
**HTTP Methods**
|
||||
🗄️ **HTTP Methods:** Request methods define the type of action you are requesting to be performed.
|
||||
|
||||
- `GET` - Requests retrieve resource information
|
||||
- `POST` - The server creates a new entry in a database
|
||||
@@ -74,17 +64,15 @@
|
||||
- `TRACE` - Performs a message loop-back test along the path to the target resource
|
||||
- `<custom>` - Some APIs use custom request methods such as `LIST`. Type in your custom methods.
|
||||
|
||||
🌈 **Make it yours:** Customizable combinations for background, foreground, and accent colors — [customize now](https://hoppscotch.io/settings).
|
||||
🌈 **Theming:** Customizable combinations for background, foreground, and accent colors — [customize now](https://hoppscotch.io/settings).
|
||||
|
||||
**Theming**
|
||||
|
||||
- Choose a theme: System (default), Light, Dark, and Black
|
||||
- Choose accent color: Green (default), Teal, Blue, Indigo, Purple, Yellow, Orange, Red, and Pink
|
||||
- Choose a theme: System preference, Light, Dark, and Black
|
||||
- Choose accent colors: Green, Teal, Blue, Indigo, Purple, Yellow, Orange, Red, and Pink
|
||||
- Distraction-free Zen mode
|
||||
|
||||
_Customized themes are synced with cloud / local session_
|
||||
_Customized themes are synced with your cloud/local session._
|
||||
|
||||
🔥 **PWA:** Install as a [PWA](https://web.dev/what-are-pwas/) on your device.
|
||||
🔥 **PWA:** Install as a [Progressive Web App](https://web.dev/progressive-web-apps) on your device.
|
||||
|
||||
- Instant loading with Service Workers
|
||||
- Offline support
|
||||
@@ -107,7 +95,7 @@ _Customized themes are synced with cloud / local session_
|
||||
|
||||
📡 **Server-Sent Events:** Receive a stream of updates from a server over an HTTP connection without resorting to polling.
|
||||
|
||||
🌩 **Socket.IO:** Send and Receive data with SocketIO server.
|
||||
🌩 **Socket.IO:** Send and Receive data with the SocketIO server.
|
||||
|
||||
🦟 **MQTT:** Subscribe and Publish to topics of an MQTT Broker.
|
||||
|
||||
@@ -127,7 +115,7 @@ _Customized themes are synced with cloud / local session_
|
||||
- OAuth 2.0
|
||||
- OIDC Access Token/PKCE
|
||||
|
||||
📢 **Headers:** Describes the format the body of your request is being sent as.
|
||||
📢 **Headers:** Describes the format the body of your request is being sent in.
|
||||
|
||||
📫 **Parameters:** Use request parameters to set varying parts in simulated requests.
|
||||
|
||||
@@ -137,14 +125,14 @@ _Customized themes are synced with cloud / local session_
|
||||
- FormData, JSON, and many more
|
||||
- Toggle between key-value and RAW input parameter list
|
||||
|
||||
👋 **Response:** Contains the status line, headers, and the message/response body.
|
||||
📮 **Response:** Contains the status line, headers, and the message/response body.
|
||||
|
||||
- Copy response to clipboard
|
||||
- Download response as a file
|
||||
- Copy the response to the clipboard
|
||||
- Download the response as a file
|
||||
- View response headers
|
||||
- View raw and preview of HTML, image, JSON, XML responses
|
||||
- View raw and preview HTML, image, JSON, and XML responses
|
||||
|
||||
⏰ **History:** Request entries are synced with cloud / local session storage to restore with a single click.
|
||||
⏰ **History:** Request entries are synced with your cloud/local session storage.
|
||||
|
||||
📁 **Collections:** Keep your API requests organized with collections and folders. Reuse them with a single click.
|
||||
|
||||
@@ -152,7 +140,32 @@ _Customized themes are synced with cloud / local session_
|
||||
- Nested folders
|
||||
- Export and import as a file or GitHub gist
|
||||
|
||||
_Collections are synced with cloud / local session storage_
|
||||
_Collections are synced with your cloud/local session storage._
|
||||
|
||||
📜 **Pre-Request Scripts:** Snippets of code associated with a request that is executed before the request is sent.
|
||||
|
||||
- Set environment variables
|
||||
- Include timestamp in the request headers
|
||||
- Send a random alphanumeric string in the URL parameters
|
||||
- Any JavaScript functions
|
||||
|
||||
👨👩👧👦 **Teams:** Helps you collaborate across your teams to design, develop, and test APIs faster.
|
||||
|
||||
- Create unlimited teams
|
||||
- Create unlimited shared collections
|
||||
- Create unlimited team members
|
||||
- Role-based access control
|
||||
- Cloud sync
|
||||
- Multiple devices
|
||||
|
||||
👥 **Workspaces:** Organize your personal and team collections environments into workspaces. Easily switch between workspaces to manage multiple projects.
|
||||
|
||||
- Create unlimited workspaces
|
||||
- Switch between personal and team workspaces
|
||||
|
||||
⌨️ **Keyboard Shortcuts:** Optimized for efficiency.
|
||||
|
||||
> **[Read our documentation on Keyboard Shortcuts](https://docs.hoppscotch.io/documentation/features/shortcuts)**
|
||||
|
||||
🌐 **Proxy:** Enable Proxy Mode from Settings to access blocked APIs.
|
||||
|
||||
@@ -161,60 +174,31 @@ _Collections are synced with cloud / local session storage_
|
||||
- Access APIs served in non-HTTPS (`http://`) endpoints
|
||||
- Use your Proxy URL
|
||||
|
||||
_Official proxy server is hosted by Hoppscotch - **[GitHub](https://github.com/hoppscotch/proxyscotch)** - **[Privacy Policy](https://docs.hoppscotch.io/privacy)**_
|
||||
|
||||
📜 **Pre-Request Scripts β:** Snippets of code associated with a request that is executed before the request is sent.
|
||||
|
||||
- Set environment variables
|
||||
- Include timestamp in the request headers
|
||||
- Send a random alphanumeric string in the URL parameters
|
||||
- Any JavaScript functions
|
||||
|
||||
📄 **API Documentation:** Create and share dynamic API documentation easily, quickly.
|
||||
|
||||
1. Add your requests to Collections and Folders
|
||||
2. Export Collections and easily share your APIs with the rest of your team
|
||||
3. Import Collections and Generate Documentation on-the-go
|
||||
|
||||
⌨️ **Keyboard Shortcuts:** Optimized for efficiency.
|
||||
|
||||
> **[Read our documentation on Keyboard Shortcuts](https://docs.hoppscotch.io/features/shortcuts)**
|
||||
_Official proxy server is hosted by Hoppscotch - **[GitHub](https://github.com/hoppscotch/proxyscotch)** - **[Privacy Policy](https://docs.hoppscotch.io/support/privacy)**._
|
||||
|
||||
🌎 **i18n:** Experience the app in your language.
|
||||
|
||||
Help us to translate Hoppscotch. Please read [`TRANSLATIONS`](TRANSLATIONS.md) for details on our [`CODE OF CONDUCT`](CODE_OF_CONDUCT.md), and the process for submitting pull requests to us.
|
||||
Help us to translate Hoppscotch. Please read [`TRANSLATIONS`](TRANSLATIONS.md) for details on our [`CODE OF CONDUCT`](CODE_OF_CONDUCT.md) and the process for submitting pull requests to us.
|
||||
|
||||
📦 **Add-ons:** Official add-ons for hoppscotch.
|
||||
☁️ **Auth + Sync:** Sign in and sync your data in real-time across all your devices.
|
||||
|
||||
- **[Proxy](https://github.com/hoppscotch/proxyscotch)** - A simple proxy server created for Hoppscotch
|
||||
- **[CLI β](https://github.com/hoppscotch/hopp-cli)** - A CLI solution for Hoppscotch
|
||||
- **[Browser Extensions](https://github.com/hoppscotch/hoppscotch-extension)** - Browser extensions that simplifies access to Hoppscotch
|
||||
|
||||
[ **Firefox**](https://addons.mozilla.org/en-US/firefox/addon/hoppscotch) | [ **Chrome**](https://chrome.google.com/webstore/detail/hoppscotch-extension-for-c/amknoiejhlmhancpahfcfcfhllgkpbld)
|
||||
|
||||
> **Extensions fixes `CORS` issues.**
|
||||
|
||||
- **[Hopp-Doc-Gen](https://github.com/hoppscotch/hopp-doc-gen)** - An API doc generator CLI for Hoppscotch
|
||||
|
||||
_Add-ons are developed and maintained under **[Hoppscotch Organization](https://github.com/hoppscotch)**._
|
||||
|
||||
☁️ **Auth + Sync:** Sign in and sync your data in real-time.
|
||||
|
||||
**Sign in with**
|
||||
**Sign in with:**
|
||||
|
||||
- GitHub
|
||||
- Google
|
||||
- Microsoft
|
||||
- Email
|
||||
- SSO (Single Sign-On)[^EE]
|
||||
|
||||
**Synchronize your data**
|
||||
**🔄 Synchronize your data:** Handoff to continue tasks on your other devices.
|
||||
|
||||
- Workspaces
|
||||
- History
|
||||
- Collections
|
||||
- Environments
|
||||
- Settings
|
||||
|
||||
✅ **Post-Request Tests β:** Write tests associated with a request that is executed after the request's response.
|
||||
✅ **Post-Request Tests:** Write tests associated with a request that is executed after the request's response.
|
||||
|
||||
- Check the status code as an integer
|
||||
- Filter response headers
|
||||
@@ -222,7 +206,7 @@ _Add-ons are developed and maintained under **[Hoppscotch Organization](https://
|
||||
- Set environment variables
|
||||
- Write JavaScript code
|
||||
|
||||
🌱 **Environments** : Environment variables allow you to store and reuse values in your requests and scripts.
|
||||
🌱 **Environments:** Environment variables allow you to store and reuse values in your requests and scripts.
|
||||
|
||||
- Unlimited environments and variables
|
||||
- Initialize through the pre-request script
|
||||
@@ -241,22 +225,31 @@ _Add-ons are developed and maintained under **[Hoppscotch Organization](https://
|
||||
|
||||
</details>
|
||||
|
||||
👨👩👧👦 **Teams β:** Helps you collaborate across your team to design, develop, and test APIs faster.
|
||||
|
||||
- Unlimited teams
|
||||
- Unlimited shared collections
|
||||
- Unlimited team members
|
||||
- Role-based access control
|
||||
- Cloud sync
|
||||
- Multiple devices
|
||||
|
||||
🚚 **Bulk Edit:** Edit key-value pairs in bulk.
|
||||
|
||||
- Entries are separated by newline
|
||||
- Keys and values are separated by `:`
|
||||
- Prepend `#` to any row you want to add but keep disabled
|
||||
|
||||
**For more features, please read our [documentation](https://docs.hoppscotch.io).**
|
||||
🎛️ **Admin dashboard:** Manage your team and invite members.
|
||||
|
||||
- Insights
|
||||
- Manage users
|
||||
- Manage teams
|
||||
|
||||
📦 **Add-ons:** Official add-ons for hoppscotch.
|
||||
|
||||
- **[Hoppscotch CLI](https://github.com/hoppscotch/hoppscotch/tree/main/packages/hoppscotch-cli)** - Command-line interface for Hoppscotch.
|
||||
- **[Proxy](https://github.com/hoppscotch/proxyscotch)** - A simple proxy server created for Hoppscotch.
|
||||
- **[Browser Extensions](https://github.com/hoppscotch/hoppscotch-extension)** - Browser extensions that enhance your Hoppscotch experience.
|
||||
|
||||
[ **Firefox**](https://addons.mozilla.org/en-US/firefox/addon/hoppscotch) | [ **Chrome**](https://chrome.google.com/webstore/detail/hoppscotch-extension-for-c/amknoiejhlmhancpahfcfcfhllgkpbld)
|
||||
|
||||
> **Extensions fix `CORS` issues.**
|
||||
|
||||
_Add-ons are developed and maintained under **[Hoppscotch Organization](https://github.com/hoppscotch)**._
|
||||
|
||||
**For a complete list of features, please read our [documentation](https://docs.hoppscotch.io).**
|
||||
|
||||
## **Demo**
|
||||
|
||||
@@ -268,56 +261,9 @@ _Add-ons are developed and maintained under **[Hoppscotch Organization](https://
|
||||
2. Click "Send" to simulate the request
|
||||
3. View the response
|
||||
|
||||
## **Built with**
|
||||
|
||||
- [HTML](https://developer.mozilla.org/en-US/docs/Web/HTML)
|
||||
- [CSS](https://developer.mozilla.org/en-US/docs/Web/CSS), [SCSS](https://sass-lang.com), [Windi CSS](https://windicss.org)
|
||||
- [JavaScript](https://developer.mozilla.org/en-US/docs/Web/JavaScript)
|
||||
- [TypeScript](https://www.typescriptlang.org)
|
||||
- [Vue](https://vuejs.org)
|
||||
- [Vite](https://vitejs.dev)
|
||||
|
||||
## **Developing**
|
||||
|
||||
0. Update [`.env.example`](https://github.com/hoppscotch/hoppscotch/blob/main/.env.example) file found in the root of the repo with your own keys and rename it to `.env`.
|
||||
|
||||
_Sample keys only work with the [production build](https://hoppscotch.io)._
|
||||
|
||||
### Browser-based development environment
|
||||
|
||||
- [GitHub codespace](https://docs.github.com/en/codespaces/developing-in-codespaces/creating-a-codespace)
|
||||
- [Gitpod](https://gitpod.io/#https://github.com/hoppscotch/hoppscotch)
|
||||
|
||||
### Local development environment
|
||||
|
||||
1. [Clone this repo](https://help.github.com/en/articles/cloning-a-repository) with git.
|
||||
2. Install pnpm using npm by running `npm install -g pnpm`.
|
||||
3. Install dependencies by running `pnpm install` within the directory that you cloned (probably `hoppscotch`).
|
||||
4. Start the development server with `pnpm run dev`.
|
||||
5. Open the development site by going to [`http://localhost:3000`](http://localhost:3000) in your browser.
|
||||
|
||||
### Docker compose
|
||||
|
||||
1. [Clone this repo](https://help.github.com/en/articles/cloning-a-repository) with git.
|
||||
2. Run `docker-compose up` within the directory that you cloned (probably `hoppscotch`).
|
||||
3. Open the development site by going to [`http://localhost:3000`](http://localhost:3000) in your browser.
|
||||
|
||||
## **Docker**
|
||||
|
||||
**Official container** [](https://hub.docker.com/r/hoppscotch/hoppscotch)
|
||||
|
||||
```bash
|
||||
docker run --rm --name hoppscotch -p 3000:3000 hoppscotch/hoppscotch:latest
|
||||
```
|
||||
|
||||
## **Releasing**
|
||||
|
||||
1. [Clone this repo](https://help.github.com/en/articles/cloning-a-repository) with git.
|
||||
2. Install pnpm using npm by running `npm install -g pnpm`.
|
||||
3. Install dependencies by running `pnpm install` within the directory that you cloned (probably `hoppscotch`).
|
||||
4. Update [`.env.example`](https://github.com/hoppscotch/hoppscotch/blob/main/packages/hoppscotch-app/.env.example) file found in `packages/hoppscotch-app` with your own keys and rename it to `.env`.
|
||||
5. Build the release files with `pnpm run generate`.
|
||||
6. Find the built project in `packages/hoppscotch-app/dist`. Host these files on any [static hosting servers](https://www.pluralsight.com/blog/software-development/where-to-host-your-jamstack-site).
|
||||
Follow our [self-hosting documentation](https://docs.hoppscotch.io/documentation/self-host/getting-started) to get started with the development environment.
|
||||
|
||||
## **Contributing**
|
||||
|
||||
@@ -335,7 +281,7 @@ See the [`CHANGELOG`](CHANGELOG.md) file for details.
|
||||
|
||||
## **Authors**
|
||||
|
||||
This project exists thanks to all the people who contribute — [contribute](CONTRIBUTING.md).
|
||||
This project owes its existence to the collective efforts of all those who contribute — [contribute now](CONTRIBUTING.md).
|
||||
|
||||
<div align="center">
|
||||
<a href="https://github.com/hoppscotch/hoppscotch/graphs/contributors">
|
||||
@@ -347,4 +293,6 @@ This project exists thanks to all the people who contribute — [contribute](CON
|
||||
|
||||
## **License**
|
||||
|
||||
This project is licensed under the [MIT License](https://opensource.org/licenses/MIT) - see the [`LICENSE`](LICENSE) file for details.
|
||||
This project is licensed under the [MIT License](https://opensource.org/licenses/MIT) — see the [`LICENSE`](LICENSE) file for details.
|
||||
|
||||
[^EE]: Enterprise edition feature. [Learn more](https://docs.hoppscotch.io/documentation/self-host/getting-started).
|
||||
|
||||
32
SECURITY.md
32
SECURITY.md
@@ -2,20 +2,38 @@
|
||||
|
||||
This document outlines security procedures and general policies for the Hoppscotch project.
|
||||
|
||||
1. [Reporting a security vulnerability](#reporting-a-security-vulnerability)
|
||||
3. [Incident response process](#incident-response-process)
|
||||
- [Security Policy](#security-policy)
|
||||
- [Reporting a security vulnerability](#reporting-a-security-vulnerability)
|
||||
- [What is not a valid vulnerability](#what-is-not-a-valid-vulnerability)
|
||||
- [Incident response process](#incident-response-process)
|
||||
|
||||
## Reporting a security vulnerability
|
||||
|
||||
Report security vulnerabilities by emailing the Hoppscotch Support team at support@hoppscotch.io.
|
||||
We use [Github Security Advisories](https://github.com/hoppscotch/hoppscotch/security/advisories) to manage vulnerability reports and collaboration.
|
||||
Someone from the Hoppscotch team shall report to you within 48 hours of the disclosure of the vulnerability in GHSA. If no response was received, please reach out to
|
||||
Hoppscotch Support at support@hoppscotch.io along with the GHSA advisory link.
|
||||
|
||||
The primary security point of contact from Hoppscotch Support team will acknowledge your email within 48 hours, and will send a more detailed response within 48 hours indicating the next steps in handling your report. After the initial reply to your report, the security team will endeavor to keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance.
|
||||
> NOTE: Since we have multiple open source components, Advisories may move into the relevant repo (for example, an XSS in a UI component might be part of [`@hoppscotch/ui`](https://github.com/hoppscotch/ui)).
|
||||
> If in doubt, open your report in `hoppscotch/hoppscotch` GHSA.
|
||||
|
||||
**Do not create a GitHub issue ticket to report a security vulnerability.**
|
||||
**Do not create a GitHub issue ticket to report a security vulnerability!**
|
||||
|
||||
The Hoppscotch team and community take all security vulnerability reports in Hoppscotch seriously. Thank you for improving the security of Hoppscotch. We appreciate your efforts and responsible disclosure and will make every effort to acknowledge your contributions.
|
||||
The Hoppscotch team takes all security vulnerability reports in Hoppscotch seriously. We appreciate your efforts and responsible disclosure and will make every effort to acknowledge your contributions.
|
||||
|
||||
Report security bugs in third-party modules to the person or team maintaining the module.
|
||||
## What is not a valid vulnerability
|
||||
We receive many reports about different sections of the Hoppscotch platform. Hence, we have a fine line we have drawn defining what is considered valid vulnerability.
|
||||
Please refrain from opening an advisory if it describes the following:
|
||||
|
||||
- A vulnerability in a dependency of Hoppscotch (unless you have practical attack with it on the Hoppscotch codebase)
|
||||
- Reports of vulnerabilities related to old runtimes (like NodeJS) or container images used by the codebase
|
||||
- Vulnerabilities present when using Hoppscotch in anything other than the defined minimum requirements that Hoppscotch supports.
|
||||
|
||||
Hoppscotch Team ensures security support for:
|
||||
- Modern Browsers (Chrome/Firefox/Safari/Edge) with versions up to 1 year old.
|
||||
- Windows versions on or above Windows 10 on Intel and ARM.
|
||||
- macOS versions dating back up to 2 years on Intel and Apple Silicon.
|
||||
- Popular Linux distributions with up-to-date packages with preference to x86/64 CPUs.
|
||||
- Docker/OCI Runtimes (preference to Docker and Podman) dating back up to 1 year.
|
||||
|
||||
## Incident response process
|
||||
|
||||
|
||||
@@ -9,26 +9,24 @@ Before you start working on a new language, please look through the [open pull r
|
||||
if there is no existing translation, you can create a new one by following these steps:
|
||||
|
||||
1. **[Fork the repository](https://github.com/hoppscotch/hoppscotch/fork).**
|
||||
2. **Checkout the `i18n` branch for latest translations.**
|
||||
3. **Create a new branch for your translation with base branch `i18n`.**
|
||||
4. **Create target language file in the [`/packages/hoppscotch-app/locales`](https://github.com/hoppscotch/hoppscotch/tree/main/packages/hoppscotch-app/locales) directory.**
|
||||
5. **Copy the contents of the source file [`/packages/hoppscotch-app/locales/en.json`](https://github.com/hoppscotch/hoppscotch/blob/main/packages/hoppscotch-app/locales/en.json) to the target language file.**
|
||||
2. **Checkout the `main` branch for latest translations.**
|
||||
3. **Create a new branch for your translation with base branch `main`.**
|
||||
4. **Create target language file in the [`/packages/hoppscotch-common/locales`](https://github.com/hoppscotch/hoppscotch/tree/main/packages/hoppscotch-common/locales) directory.**
|
||||
5. **Copy the contents of the source file [`/packages/hoppscotch-common/locales/en.json`](https://github.com/hoppscotch/hoppscotch/blob/main/packages/hoppscotch-common/locales/en.json) to the target language file.**
|
||||
6. **Translate the strings in the target language file.**
|
||||
7. **Add your language entry to [`/packages/hoppscotch-app/languages.json`](https://github.com/hoppscotch/hoppscotch/blob/main/packages/hoppscotch-app/languages.json).**
|
||||
8. **Save & commit changes.**
|
||||
7. **Add your language entry to [`/packages/hoppscotch-common/languages.json`](https://github.com/hoppscotch/hoppscotch/blob/main/packages/hoppscotch-common/languages.json).**
|
||||
8. **Save and commit changes.**
|
||||
9. **Send a pull request.**
|
||||
|
||||
_You may send a pull request before all steps above are complete: e.g., you may want to ask for help with translations, or getting tests to pass. However, your pull request will not be merged until all steps above are complete._
|
||||
|
||||
`i18n` branch will be merged into `main` branch once every week.
|
||||
|
||||
Completing an initial translation of the whole site is a fairly large task. One way to break that task up is to work with other translators through pull requests on your fork. You can also [add collaborators to your fork](https://help.github.com/en/github/setting-up-and-managing-your-github-user-account/inviting-collaborators-to-a-personal-repository) if you'd like to invite other translators to commit directly to your fork and share responsibility for merging pull requests.
|
||||
|
||||
## Updating a translation
|
||||
|
||||
### Corrections
|
||||
|
||||
If you notice spelling or grammar errors, typos, or opportunities for better phrasing, open a pull request with your suggested fix. If you see a problem that you aren't sure of or don't have time to fix, open an issue.
|
||||
If you notice spelling or grammar errors, typos, or opportunities for better phrasing, open a pull request with your suggested fix. If you see a problem that you aren't sure of or don't have time to fix, [open an issue](https://github.com/hoppscotch/hoppscotch/issues/new/choose).
|
||||
|
||||
### Broken links
|
||||
|
||||
|
||||
19
aio-multiport-setup.Caddyfile
Normal file
19
aio-multiport-setup.Caddyfile
Normal file
@@ -0,0 +1,19 @@
|
||||
:3000 {
|
||||
try_files {path} /
|
||||
root * /site/selfhost-web
|
||||
file_server
|
||||
}
|
||||
|
||||
:3100 {
|
||||
try_files {path} /
|
||||
root * /site/sh-admin-multiport-setup
|
||||
file_server
|
||||
}
|
||||
|
||||
:3170 {
|
||||
reverse_proxy localhost:8080
|
||||
}
|
||||
|
||||
:80 {
|
||||
respond 404
|
||||
}
|
||||
37
aio-subpath-access.Caddyfile
Normal file
37
aio-subpath-access.Caddyfile
Normal file
@@ -0,0 +1,37 @@
|
||||
:3000 {
|
||||
respond 404
|
||||
}
|
||||
|
||||
:3100 {
|
||||
respond 404
|
||||
}
|
||||
|
||||
:3170 {
|
||||
reverse_proxy localhost:8080
|
||||
}
|
||||
|
||||
:80 {
|
||||
# Serve the `selfhost-web` SPA by default
|
||||
root * /site/selfhost-web
|
||||
file_server
|
||||
|
||||
handle_path /admin* {
|
||||
root * /site/sh-admin-subpath-access
|
||||
file_server
|
||||
|
||||
# Ensures any non-existent file in the server is routed to the SPA
|
||||
try_files {path} /
|
||||
}
|
||||
|
||||
# Handle requests under `/backend*` path
|
||||
handle_path /backend* {
|
||||
reverse_proxy localhost:8080
|
||||
}
|
||||
|
||||
# Catch-all route for unknown paths, serves `selfhost-web` SPA
|
||||
handle {
|
||||
root * /site/selfhost-web
|
||||
file_server
|
||||
try_files {path} /
|
||||
}
|
||||
}
|
||||
73
aio_run.mjs
Normal file
73
aio_run.mjs
Normal file
@@ -0,0 +1,73 @@
|
||||
#!/usr/local/bin/node
|
||||
// @ts-check
|
||||
|
||||
import { execSync, spawn } from "child_process"
|
||||
import fs from "fs"
|
||||
import process from "process"
|
||||
|
||||
function runChildProcessWithPrefix(command, args, prefix) {
|
||||
const childProcess = spawn(command, args);
|
||||
|
||||
childProcess.stdout.on('data', (data) => {
|
||||
const output = data.toString().trim().split('\n');
|
||||
output.forEach((line) => {
|
||||
console.log(`${prefix} | ${line}`);
|
||||
});
|
||||
});
|
||||
|
||||
childProcess.stderr.on('data', (data) => {
|
||||
const error = data.toString().trim().split('\n');
|
||||
error.forEach((line) => {
|
||||
console.error(`${prefix} | ${line}`);
|
||||
});
|
||||
});
|
||||
|
||||
childProcess.on('close', (code) => {
|
||||
console.log(`${prefix} Child process exited with code ${code}`);
|
||||
});
|
||||
|
||||
childProcess.on('error', (stuff) => {
|
||||
console.log("error")
|
||||
console.log(stuff)
|
||||
})
|
||||
|
||||
return childProcess
|
||||
}
|
||||
|
||||
const envFileContent = Object.entries(process.env)
|
||||
.filter(([env]) => env.startsWith("VITE_"))
|
||||
.map(([env, val]) => `${env}=${
|
||||
(val.startsWith("\"") && val.endsWith("\""))
|
||||
? val
|
||||
: `"${val}"`
|
||||
}`)
|
||||
.join("\n")
|
||||
|
||||
fs.writeFileSync("build.env", envFileContent)
|
||||
|
||||
execSync(`npx import-meta-env -x build.env -e build.env -p "/site/**/*"`)
|
||||
|
||||
fs.rmSync("build.env")
|
||||
|
||||
const caddyFileName = process.env.ENABLE_SUBPATH_BASED_ACCESS === 'true' ? 'aio-subpath-access.Caddyfile' : 'aio-multiport-setup.Caddyfile'
|
||||
const caddyProcess = runChildProcessWithPrefix("caddy", ["run", "--config", `/etc/caddy/${caddyFileName}`, "--adapter", "caddyfile"], "App/Admin Dashboard Caddy")
|
||||
const backendProcess = runChildProcessWithPrefix("pnpm", ["run", "start:prod"], "Backend Server")
|
||||
|
||||
caddyProcess.on("exit", (code) => {
|
||||
console.log(`Exiting process because Caddy Server exited with code ${code}`)
|
||||
process.exit(code)
|
||||
})
|
||||
|
||||
backendProcess.on("exit", (code) => {
|
||||
console.log(`Exiting process because Backend Server exited with code ${code}`)
|
||||
process.exit(code)
|
||||
})
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log("SIGINT received, exiting...")
|
||||
|
||||
caddyProcess.kill("SIGINT")
|
||||
backendProcess.kill("SIGINT")
|
||||
|
||||
process.exit(0)
|
||||
})
|
||||
48
docker-compose.deploy.yml
Normal file
48
docker-compose.deploy.yml
Normal file
@@ -0,0 +1,48 @@
|
||||
# THIS IS NOT TO BE USED FOR PERSONAL DEPLOYMENTS!
|
||||
# Internal Docker Compose Image used for internal testing deployments
|
||||
|
||||
version: "3.7"
|
||||
|
||||
services:
|
||||
hoppscotch-db:
|
||||
image: postgres:15
|
||||
user: postgres
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: testpass
|
||||
POSTGRES_DB: hoppscotch
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"sh -c 'pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}'"
|
||||
]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
hoppscotch-aio:
|
||||
container_name: hoppscotch-aio
|
||||
build:
|
||||
dockerfile: prod.Dockerfile
|
||||
context: .
|
||||
target: aio
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch
|
||||
- ENABLE_SUBPATH_BASED_ACCESS=true
|
||||
env_file:
|
||||
- ./.env
|
||||
depends_on:
|
||||
hoppscotch-db:
|
||||
condition: service_healthy
|
||||
command: ["sh", "-c", "pnpm exec prisma migrate deploy && node /usr/src/app/aio_run.mjs"]
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- curl
|
||||
- '-f'
|
||||
- 'http://localhost:80'
|
||||
interval: 2s
|
||||
timeout: 10s
|
||||
retries: 30
|
||||
|
||||
@@ -1,23 +1,155 @@
|
||||
# To make it easier to self-host, we have a preset docker compose config that also
|
||||
# has a container with a Postgres instance running.
|
||||
# You can tweak around this file to match your instances
|
||||
version: "3.7"
|
||||
|
||||
services:
|
||||
web:
|
||||
# This service runs the backend app in the port 3170
|
||||
hoppscotch-backend:
|
||||
container_name: hoppscotch-backend
|
||||
build:
|
||||
dockerfile: prod.Dockerfile
|
||||
context: .
|
||||
target: backend
|
||||
env_file:
|
||||
- ./.env
|
||||
restart: always
|
||||
environment:
|
||||
# Edit the below line to match your PostgresDB URL if you have an outside DB (make sure to update the .env file as well)
|
||||
- DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch?connect_timeout=300
|
||||
- PORT=8080
|
||||
volumes:
|
||||
- "./.hoppscotch:/app/.hoppscotch"
|
||||
- "./assets:/app/assets"
|
||||
- "./directives:/app/directives"
|
||||
- "./layouts:/app/layouts"
|
||||
- "./middleware:/app/middleware"
|
||||
- "./pages:/app/pages"
|
||||
- "./plugins:/app/plugins"
|
||||
- "./static:/app/static"
|
||||
- "./store:/app/store"
|
||||
- "./components:/app/components"
|
||||
- "./helpers:/app/helpers"
|
||||
# Uncomment the line below when modifying code. Only applicable when using the "dev" target.
|
||||
# - ./packages/hoppscotch-backend/:/usr/src/app
|
||||
- /usr/src/app/node_modules/
|
||||
depends_on:
|
||||
hoppscotch-db:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "3180:80"
|
||||
- "3170:3170"
|
||||
|
||||
# The main hoppscotch app. This will be hosted at port 3000
|
||||
# NOTE: To do TLS or play around with how the app is hosted, you can look into the Caddyfile for
|
||||
# the SH admin dashboard server at packages/hoppscotch-selfhost-web/Caddyfile
|
||||
hoppscotch-app:
|
||||
container_name: hoppscotch-app
|
||||
build:
|
||||
dockerfile: prod.Dockerfile
|
||||
context: .
|
||||
target: app
|
||||
env_file:
|
||||
- ./.env
|
||||
depends_on:
|
||||
- hoppscotch-backend
|
||||
ports:
|
||||
- "3080:80"
|
||||
- "3000:3000"
|
||||
|
||||
# The Self Host dashboard for managing the app. This will be hosted at port 3100
|
||||
# NOTE: To do TLS or play around with how the app is hosted, you can look into the Caddyfile for
|
||||
# the SH admin dashboard server at packages/hoppscotch-sh-admin/Caddyfile
|
||||
hoppscotch-sh-admin:
|
||||
container_name: hoppscotch-sh-admin
|
||||
build:
|
||||
dockerfile: prod.Dockerfile
|
||||
context: .
|
||||
target: sh_admin
|
||||
env_file:
|
||||
- ./.env
|
||||
depends_on:
|
||||
- hoppscotch-backend
|
||||
ports:
|
||||
- "3280:80"
|
||||
- "3100:3100"
|
||||
|
||||
# The service that spins up all 3 services at once in one container
|
||||
hoppscotch-aio:
|
||||
container_name: hoppscotch-aio
|
||||
restart: unless-stopped
|
||||
build:
|
||||
dockerfile: prod.Dockerfile
|
||||
context: .
|
||||
target: aio
|
||||
env_file:
|
||||
- ./.env
|
||||
depends_on:
|
||||
hoppscotch-db:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "3100:3100"
|
||||
- "3170:3170"
|
||||
- "3080:80"
|
||||
|
||||
# The preset DB service, you can delete/comment the below lines if
|
||||
# you are using an external postgres instance
|
||||
# This will be exposed at port 5432
|
||||
hoppscotch-db:
|
||||
image: postgres:15
|
||||
ports:
|
||||
- "5432:5432"
|
||||
user: postgres
|
||||
environment:
|
||||
HOST: 0.0.0.0
|
||||
command: "pnpm run dev"
|
||||
# The default user defined by the docker image
|
||||
POSTGRES_USER: postgres
|
||||
# NOTE: Please UPDATE THIS PASSWORD!
|
||||
POSTGRES_PASSWORD: testpass
|
||||
POSTGRES_DB: hoppscotch
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"sh -c 'pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}'",
|
||||
]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
# All the services listed below are deprececated
|
||||
hoppscotch-old-backend:
|
||||
container_name: hoppscotch-old-backend
|
||||
build:
|
||||
dockerfile: packages/hoppscotch-backend/Dockerfile
|
||||
context: .
|
||||
target: prod
|
||||
env_file:
|
||||
- ./.env
|
||||
restart: always
|
||||
environment:
|
||||
# Edit the below line to match your PostgresDB URL if you have an outside DB (make sure to update the .env file as well)
|
||||
- DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch?connect_timeout=300
|
||||
- PORT=3000
|
||||
volumes:
|
||||
# Uncomment the line below when modifying code. Only applicable when using the "dev" target.
|
||||
# - ./packages/hoppscotch-backend/:/usr/src/app
|
||||
- /usr/src/app/node_modules/
|
||||
depends_on:
|
||||
hoppscotch-db:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "3170:3000"
|
||||
|
||||
hoppscotch-old-app:
|
||||
container_name: hoppscotch-old-app
|
||||
build:
|
||||
dockerfile: packages/hoppscotch-selfhost-web/Dockerfile
|
||||
context: .
|
||||
env_file:
|
||||
- ./.env
|
||||
depends_on:
|
||||
- hoppscotch-old-backend
|
||||
ports:
|
||||
- "3000:8080"
|
||||
|
||||
hoppscotch-old-sh-admin:
|
||||
container_name: hoppscotch-old-sh-admin
|
||||
build:
|
||||
dockerfile: packages/hoppscotch-sh-admin/Dockerfile
|
||||
context: .
|
||||
env_file:
|
||||
- ./.env
|
||||
depends_on:
|
||||
- hoppscotch-old-backend
|
||||
ports:
|
||||
- "3100:8080"
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
},
|
||||
"hosting": {
|
||||
"predeploy": [
|
||||
"mv packages/hoppscotch-app/.env.example packages/hoppscotch-app/.env && npm install -g pnpm && pnpm i && pnpm run generate"
|
||||
"mv .env.example .env && npm install -g pnpm && pnpm i && pnpm run generate"
|
||||
],
|
||||
"public": "packages/hoppscotch-app/dist",
|
||||
"public": "packages/hoppscotch-web/dist",
|
||||
"ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
|
||||
"rewrites": [
|
||||
{
|
||||
|
||||
18
healthcheck.sh
Normal file
18
healthcheck.sh
Normal file
@@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
|
||||
curlCheck() {
|
||||
if ! curl -s --head "$1" | head -n 1 | grep -q "HTTP/1.[01] [23].."; then
|
||||
echo "URL request failed!"
|
||||
exit 1
|
||||
else
|
||||
echo "URL request succeeded!"
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "$ENABLE_SUBPATH_BASED_ACCESS" = "true" ]; then
|
||||
curlCheck "http://localhost:80/backend/ping"
|
||||
else
|
||||
curlCheck "http://localhost:3000"
|
||||
curlCheck "http://localhost:3100"
|
||||
curlCheck "http://localhost:3170/ping"
|
||||
fi
|
||||
@@ -4,13 +4,13 @@
|
||||
|
||||
[build]
|
||||
base = "/"
|
||||
publish = "packages/hoppscotch-app/dist"
|
||||
publish = "packages/hoppscotch-web/dist"
|
||||
command = "npx pnpm i --store=node_modules/.pnpm-store && npx pnpm run generate"
|
||||
|
||||
[[headers]]
|
||||
for = "/*"
|
||||
[headers.values]
|
||||
X-Frame-Options = "DENY"
|
||||
X-Frame-Options = "SAMEORIGIN"
|
||||
X-XSS-Protection = "1; mode=block"
|
||||
|
||||
[[redirects]]
|
||||
|
||||
34
package.json
34
package.json
@@ -9,25 +9,39 @@
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"prepare": "husky install",
|
||||
"dev": "pnpm -r do-dev",
|
||||
"gen-gql": "cross-env GQL_SCHEMA_EMIT_LOCATION='../../../gql-gen/backend-schema.gql' pnpm -r generate-gql-sdl",
|
||||
"generate": "pnpm -r do-build-prod",
|
||||
"start": "http-server packages/hoppscotch-app/dist -p 3000",
|
||||
"start": "http-server packages/hoppscotch-selfhost-web/dist -p 3000",
|
||||
"lint": "pnpm -r do-lint",
|
||||
"typecheck": "pnpm -r do-typecheck",
|
||||
"lintfix": "pnpm -r do-lintfix",
|
||||
"pre-commit": "pnpm -r do-lint && pnpm -r do-typecheck",
|
||||
"test": "pnpm -r do-test"
|
||||
"test": "pnpm -r do-test",
|
||||
"generate-ui": "pnpm -r do-build-ui"
|
||||
},
|
||||
"workspaces": [
|
||||
"./packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"husky": "^7.0.4",
|
||||
"lint-staged": "^12.3.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^16.2.3",
|
||||
"@commitlint/config-conventional": "^16.2.1",
|
||||
"@types/node": "^17.0.24",
|
||||
"http-server": "^14.1.1"
|
||||
"@commitlint/cli": "16.3.0",
|
||||
"@commitlint/config-conventional": "16.2.4",
|
||||
"@hoppscotch/ui": "0.2.0",
|
||||
"@types/node": "17.0.27",
|
||||
"cross-env": "7.0.3",
|
||||
"http-server": "14.1.1",
|
||||
"husky": "7.0.4",
|
||||
"lint-staged": "12.4.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"vue": "3.3.9"
|
||||
},
|
||||
"packageExtensions": {
|
||||
"@hoppscotch/httpsnippet": {
|
||||
"dependencies": {
|
||||
"ajv": "6.12.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,16 +17,16 @@
|
||||
"types": "dist/index.d.ts",
|
||||
"sideEffects": false,
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.2.0"
|
||||
"@codemirror/language": "6.10.1",
|
||||
"@lezer/highlight": "1.2.0",
|
||||
"@lezer/lr": "1.3.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lezer/generator": "^1.1.0",
|
||||
"mocha": "^9.2.2",
|
||||
"rollup": "^2.70.2",
|
||||
"rollup-plugin-dts": "^4.2.1",
|
||||
"rollup-plugin-ts": "^2.0.7",
|
||||
"typescript": "^4.6.3"
|
||||
"@lezer/generator": "1.5.1",
|
||||
"mocha": "9.2.2",
|
||||
"rollup": "3.29.4",
|
||||
"rollup-plugin-dts": "6.0.2",
|
||||
"rollup-plugin-ts": "3.4.5",
|
||||
"typescript": "5.2.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
1
packages/hoppscotch-backend/.dockerignore
Normal file
1
packages/hoppscotch-backend/.dockerignore
Normal file
@@ -0,0 +1 @@
|
||||
./node_modules
|
||||
27
packages/hoppscotch-backend/.eslintrc.js
Normal file
27
packages/hoppscotch-backend/.eslintrc.js
Normal file
@@ -0,0 +1,27 @@
|
||||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
project: 'tsconfig.json',
|
||||
tsconfigRootDir: __dirname,
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: ['@typescript-eslint/eslint-plugin'],
|
||||
extends: [
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:prettier/recommended',
|
||||
],
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
jest: true,
|
||||
},
|
||||
ignorePatterns: ['.eslintrc.js'],
|
||||
rules: {
|
||||
'@typescript-eslint/interface-name-prefix': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
"no-empty-function": "off",
|
||||
"@typescript-eslint/no-empty-function": "error"
|
||||
},
|
||||
};
|
||||
43
packages/hoppscotch-backend/.gitignore
vendored
Normal file
43
packages/hoppscotch-backend/.gitignore
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
|
||||
.vscode
|
||||
|
||||
.env
|
||||
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Tests
|
||||
/coverage
|
||||
/.nyc_output
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# Generated artifacts (GQL Schema SDL generation etc.)
|
||||
gen/
|
||||
4
packages/hoppscotch-backend/.prettierrc
Normal file
4
packages/hoppscotch-backend/.prettierrc
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
38
packages/hoppscotch-backend/Dockerfile
Normal file
38
packages/hoppscotch-backend/Dockerfile
Normal file
@@ -0,0 +1,38 @@
|
||||
FROM node:20.12.2 AS builder
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# # Install pnpm
|
||||
RUN npm i -g pnpm
|
||||
|
||||
COPY .env .
|
||||
COPY pnpm-lock.yaml .
|
||||
RUN pnpm fetch
|
||||
|
||||
ENV APP_PORT=${PORT}
|
||||
ENV DB_URL=${DATABASE_URL}
|
||||
|
||||
# # PNPM package install
|
||||
COPY ./packages/hoppscotch-backend .
|
||||
RUN pnpm i --filter hoppscotch-backend
|
||||
|
||||
# Prisma bits
|
||||
RUN pnpm exec prisma generate
|
||||
|
||||
FROM builder AS dev
|
||||
|
||||
ENV PRODUCTION="false"
|
||||
|
||||
CMD ["pnpm", "run", "start:dev"]
|
||||
|
||||
EXPOSE 3170
|
||||
|
||||
|
||||
FROM builder AS prod
|
||||
|
||||
ENV PRODUCTION="true"
|
||||
|
||||
CMD ["pnpm", "run", "start:prod"]
|
||||
|
||||
EXPOSE 3170
|
||||
|
||||
3
packages/hoppscotch-backend/backend.Caddyfile
Normal file
3
packages/hoppscotch-backend/backend.Caddyfile
Normal file
@@ -0,0 +1,3 @@
|
||||
:80 :3170 {
|
||||
reverse_proxy localhost:8080
|
||||
}
|
||||
0
packages/hoppscotch-backend/cross-env
Normal file
0
packages/hoppscotch-backend/cross-env
Normal file
0
packages/hoppscotch-backend/eslint
Normal file
0
packages/hoppscotch-backend/eslint
Normal file
1
packages/hoppscotch-backend/global.d.ts
vendored
Normal file
1
packages/hoppscotch-backend/global.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import '@relmify/jest-fp-ts';
|
||||
1
packages/hoppscotch-backend/jest.setup.js
Normal file
1
packages/hoppscotch-backend/jest.setup.js
Normal file
@@ -0,0 +1 @@
|
||||
require('@relmify/jest-fp-ts');
|
||||
9
packages/hoppscotch-backend/nest-cli.json
Normal file
9
packages/hoppscotch-backend/nest-cli.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"assets": [{ "include": "mailer/templates/**/*", "outDir": "dist" }],
|
||||
"watchAssets": true
|
||||
}
|
||||
}
|
||||
128
packages/hoppscotch-backend/package.json
Normal file
128
packages/hoppscotch-backend/package.json
Normal file
@@ -0,0 +1,128 @@
|
||||
{
|
||||
"name": "hoppscotch-backend",
|
||||
"version": "2024.7.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"prebuild": "rimraf dist",
|
||||
"build": "nest build",
|
||||
"generate-gql-sdl": "cross-env GQL_SCHEMA_EMIT_LOCATION='../../../gql-gen/backend-schema.gql' GENERATE_GQL_SCHEMA=true WHITELISTED_ORIGINS='' nest start",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"postinstall": "prisma generate && pnpm run generate-gql-sdl",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"do-test": "pnpm run test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/server": "4.9.5",
|
||||
"@nestjs-modules/mailer": "1.9.1",
|
||||
"@nestjs/apollo": "12.0.9",
|
||||
"@nestjs/common": "10.2.7",
|
||||
"@nestjs/config": "3.1.1",
|
||||
"@nestjs/core": "10.2.7",
|
||||
"@nestjs/graphql": "12.0.9",
|
||||
"@nestjs/jwt": "10.1.1",
|
||||
"@nestjs/passport": "10.0.2",
|
||||
"@nestjs/platform-express": "10.2.7",
|
||||
"@nestjs/schedule": "4.0.1",
|
||||
"@nestjs/swagger": "7.4.0",
|
||||
"@nestjs/terminus": "10.2.3",
|
||||
"@nestjs/throttler": "5.0.1",
|
||||
"@prisma/client": "5.8.1",
|
||||
"argon2": "0.30.3",
|
||||
"bcrypt": "5.1.0",
|
||||
"class-transformer": "0.5.1",
|
||||
"class-validator": "0.14.1",
|
||||
"cookie": "0.5.0",
|
||||
"cookie-parser": "1.4.6",
|
||||
"cron": "3.1.6",
|
||||
"express": "4.18.2",
|
||||
"express-session": "1.17.3",
|
||||
"fp-ts": "2.13.1",
|
||||
"graphql": "16.8.1",
|
||||
"graphql-query-complexity": "0.12.0",
|
||||
"graphql-redis-subscriptions": "2.6.0",
|
||||
"graphql-subscriptions": "2.0.0",
|
||||
"handlebars": "4.7.7",
|
||||
"io-ts": "2.2.16",
|
||||
"luxon": "3.2.1",
|
||||
"nodemailer": "6.9.1",
|
||||
"passport": "0.6.0",
|
||||
"passport-github2": "0.1.12",
|
||||
"passport-google-oauth20": "2.0.0",
|
||||
"passport-jwt": "4.0.1",
|
||||
"passport-local": "1.0.0",
|
||||
"passport-microsoft": "1.0.0",
|
||||
"posthog-node": "3.6.3",
|
||||
"prisma": "5.8.1",
|
||||
"reflect-metadata": "0.1.13",
|
||||
"rimraf": "3.0.2",
|
||||
"rxjs": "7.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "10.2.1",
|
||||
"@nestjs/schematics": "10.0.3",
|
||||
"@nestjs/testing": "10.2.7",
|
||||
"@relmify/jest-fp-ts": "2.0.2",
|
||||
"@types/argon2": "0.15.0",
|
||||
"@types/bcrypt": "5.0.0",
|
||||
"@types/cookie": "0.5.1",
|
||||
"@types/cookie-parser": "1.4.3",
|
||||
"@types/express": "4.17.14",
|
||||
"@types/jest": "29.4.0",
|
||||
"@types/luxon": "3.2.0",
|
||||
"@types/node": "18.11.10",
|
||||
"@types/nodemailer": "6.4.7",
|
||||
"@types/passport-github2": "1.2.5",
|
||||
"@types/passport-google-oauth20": "2.0.11",
|
||||
"@types/passport-jwt": "3.0.8",
|
||||
"@types/passport-microsoft": "0.0.0",
|
||||
"@types/supertest": "2.0.12",
|
||||
"@typescript-eslint/eslint-plugin": "5.45.0",
|
||||
"@typescript-eslint/parser": "5.45.0",
|
||||
"cross-env": "7.0.3",
|
||||
"eslint": "8.29.0",
|
||||
"eslint-config-prettier": "8.5.0",
|
||||
"eslint-plugin-prettier": "4.2.1",
|
||||
"jest": "29.4.1",
|
||||
"jest-mock-extended": "3.0.1",
|
||||
"jwt": "link:@types/nestjs/jwt",
|
||||
"prettier": "2.8.4",
|
||||
"source-map-support": "0.5.21",
|
||||
"supertest": "6.3.2",
|
||||
"ts-jest": "29.0.5",
|
||||
"ts-loader": "9.4.2",
|
||||
"ts-node": "10.9.1",
|
||||
"tsconfig-paths": "4.1.1",
|
||||
"typescript": "4.9.3"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"setupFilesAfterEnv": [
|
||||
"../jest.setup.js"
|
||||
],
|
||||
"preset": "ts-jest",
|
||||
"clearMocks": true,
|
||||
"collectCoverage": true,
|
||||
"coverageDirectory": "coverage",
|
||||
"coverageProvider": "v8",
|
||||
"rootDir": "src",
|
||||
"moduleNameMapper": {
|
||||
"^src/(.*)$": "<rootDir>/$1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ReqType" AS ENUM ('REST', 'GQL');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TeamMemberRole" AS ENUM ('OWNER', 'VIEWER', 'EDITOR');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Team" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "Team_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TeamMember" (
|
||||
"id" TEXT NOT NULL,
|
||||
"role" "TeamMemberRole" NOT NULL,
|
||||
"userUid" TEXT NOT NULL,
|
||||
"teamID" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "TeamMember_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TeamInvitation" (
|
||||
"id" TEXT NOT NULL,
|
||||
"teamID" TEXT NOT NULL,
|
||||
"creatorUid" TEXT NOT NULL,
|
||||
"inviteeEmail" TEXT NOT NULL,
|
||||
"inviteeRole" "TeamMemberRole" NOT NULL,
|
||||
|
||||
CONSTRAINT "TeamInvitation_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TeamCollection" (
|
||||
"id" TEXT NOT NULL,
|
||||
"parentID" TEXT,
|
||||
"teamID" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"orderIndex" INTEGER NOT NULL,
|
||||
"createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedOn" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "TeamCollection_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TeamRequest" (
|
||||
"id" TEXT NOT NULL,
|
||||
"collectionID" TEXT NOT NULL,
|
||||
"teamID" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"request" JSONB NOT NULL,
|
||||
"orderIndex" INTEGER NOT NULL,
|
||||
"createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedOn" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "TeamRequest_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Shortcode" (
|
||||
"id" TEXT NOT NULL,
|
||||
"request" JSONB NOT NULL,
|
||||
"creatorUid" TEXT,
|
||||
"createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Shortcode_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TeamEnvironment" (
|
||||
"id" TEXT NOT NULL,
|
||||
"teamID" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"variables" JSONB NOT NULL,
|
||||
|
||||
CONSTRAINT "TeamEnvironment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"uid" TEXT NOT NULL,
|
||||
"displayName" TEXT,
|
||||
"email" TEXT,
|
||||
"photoURL" TEXT,
|
||||
"isAdmin" BOOLEAN NOT NULL DEFAULT false,
|
||||
"refreshToken" TEXT,
|
||||
"currentRESTSession" JSONB,
|
||||
"currentGQLSession" JSONB,
|
||||
"createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("uid")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Account" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"provider" TEXT NOT NULL,
|
||||
"providerAccountId" TEXT NOT NULL,
|
||||
"providerRefreshToken" TEXT,
|
||||
"providerAccessToken" TEXT,
|
||||
"providerScope" TEXT,
|
||||
"loggedIn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "VerificationToken" (
|
||||
"deviceIdentifier" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"userUid" TEXT NOT NULL,
|
||||
"expiresOn" TIMESTAMP(3) NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "UserSettings" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userUid" TEXT NOT NULL,
|
||||
"properties" JSONB NOT NULL,
|
||||
"updatedOn" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "UserSettings_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "UserHistory" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userUid" TEXT NOT NULL,
|
||||
"reqType" "ReqType" NOT NULL,
|
||||
"request" JSONB NOT NULL,
|
||||
"responseMetadata" JSONB NOT NULL,
|
||||
"isStarred" BOOLEAN NOT NULL,
|
||||
"executedOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "UserHistory_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "UserEnvironment" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userUid" TEXT NOT NULL,
|
||||
"name" TEXT,
|
||||
"variables" JSONB NOT NULL,
|
||||
"isGlobal" BOOLEAN NOT NULL,
|
||||
|
||||
CONSTRAINT "UserEnvironment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "InvitedUsers" (
|
||||
"adminUid" TEXT NOT NULL,
|
||||
"adminEmail" TEXT NOT NULL,
|
||||
"inviteeEmail" TEXT NOT NULL,
|
||||
"invitedOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "UserRequest" (
|
||||
"id" TEXT NOT NULL,
|
||||
"collectionID" TEXT NOT NULL,
|
||||
"userUid" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"request" JSONB NOT NULL,
|
||||
"type" "ReqType" NOT NULL,
|
||||
"orderIndex" INTEGER NOT NULL,
|
||||
"createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedOn" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "UserRequest_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "UserCollection" (
|
||||
"id" TEXT NOT NULL,
|
||||
"parentID" TEXT,
|
||||
"userUid" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"orderIndex" INTEGER NOT NULL,
|
||||
"type" "ReqType" NOT NULL,
|
||||
"createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedOn" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "UserCollection_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TeamMember_teamID_userUid_key" ON "TeamMember"("teamID", "userUid");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TeamInvitation_teamID_idx" ON "TeamInvitation"("teamID");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TeamInvitation_teamID_inviteeEmail_key" ON "TeamInvitation"("teamID", "inviteeEmail");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Shortcode_id_creatorUid_key" ON "Shortcode"("id", "creatorUid");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "VerificationToken_deviceIdentifier_token_key" ON "VerificationToken"("deviceIdentifier", "token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "UserSettings_userUid_key" ON "UserSettings"("userUid");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "InvitedUsers_inviteeEmail_key" ON "InvitedUsers"("inviteeEmail");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_teamID_fkey" FOREIGN KEY ("teamID") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TeamInvitation" ADD CONSTRAINT "TeamInvitation_teamID_fkey" FOREIGN KEY ("teamID") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TeamCollection" ADD CONSTRAINT "TeamCollection_parentID_fkey" FOREIGN KEY ("parentID") REFERENCES "TeamCollection"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TeamCollection" ADD CONSTRAINT "TeamCollection_teamID_fkey" FOREIGN KEY ("teamID") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TeamRequest" ADD CONSTRAINT "TeamRequest_collectionID_fkey" FOREIGN KEY ("collectionID") REFERENCES "TeamCollection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TeamRequest" ADD CONSTRAINT "TeamRequest_teamID_fkey" FOREIGN KEY ("teamID") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TeamEnvironment" ADD CONSTRAINT "TeamEnvironment_teamID_fkey" FOREIGN KEY ("teamID") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VerificationToken" ADD CONSTRAINT "VerificationToken_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "UserSettings" ADD CONSTRAINT "UserSettings_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "UserHistory" ADD CONSTRAINT "UserHistory_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "UserEnvironment" ADD CONSTRAINT "UserEnvironment_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InvitedUsers" ADD CONSTRAINT "InvitedUsers_adminUid_fkey" FOREIGN KEY ("adminUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "UserRequest" ADD CONSTRAINT "UserRequest_collectionID_fkey" FOREIGN KEY ("collectionID") REFERENCES "UserCollection"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "UserRequest" ADD CONSTRAINT "UserRequest_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "UserCollection" ADD CONSTRAINT "UserCollection_parentID_fkey" FOREIGN KEY ("parentID") REFERENCES "UserCollection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "UserCollection" ADD CONSTRAINT "UserCollection_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[id]` on the table `Shortcode` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Shortcode" ADD COLUMN "embedProperties" JSONB,
|
||||
ADD COLUMN "updatedOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Shortcode_id_key" ON "Shortcode"("id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Shortcode" ADD CONSTRAINT "Shortcode_creatorUid_fkey" FOREIGN KEY ("creatorUid") REFERENCES "User"("uid") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "InfraConfig" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"value" TEXT,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedOn" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "InfraConfig_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "InfraConfig_name_key" ON "InfraConfig"("name");
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "TeamCollection" ADD COLUMN "data" JSONB;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "UserCollection" ADD COLUMN "data" JSONB;
|
||||
@@ -0,0 +1,22 @@
|
||||
-- This is a custom migration file which is not generated by Prisma.
|
||||
-- The aim of this migration is to add text search indices to the TeamCollection and TeamRequest tables.
|
||||
|
||||
-- Create Extension
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
|
||||
-- Create GIN Trigram Index for Team Collection title
|
||||
CREATE INDEX
|
||||
"TeamCollection_title_trgm_idx"
|
||||
ON
|
||||
"TeamCollection"
|
||||
USING
|
||||
GIN (title gin_trgm_ops);
|
||||
|
||||
-- Create GIN Trigram Index for Team Collection title
|
||||
CREATE INDEX
|
||||
"TeamRequest_title_trgm_idx"
|
||||
ON
|
||||
"TeamRequest"
|
||||
USING
|
||||
GIN (title gin_trgm_ops);
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "lastLoggedOn" TIMESTAMP(3);
|
||||
@@ -0,0 +1,19 @@
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PersonalAccessToken" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userUid" TEXT NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"expiresOn" TIMESTAMP(3),
|
||||
"createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedOn" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "PersonalAccessToken_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PersonalAccessToken_token_key" ON "PersonalAccessToken"("token");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PersonalAccessToken" ADD CONSTRAINT "PersonalAccessToken_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "lastActiveOn" TIMESTAMP(3);
|
||||
@@ -0,0 +1,15 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "InfraToken" (
|
||||
"id" TEXT NOT NULL,
|
||||
"creatorUid" TEXT NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"expiresOn" TIMESTAMP(3),
|
||||
"createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "InfraToken_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "InfraToken_token_key" ON "InfraToken"("token");
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
||||
244
packages/hoppscotch-backend/prisma/schema.prisma
Normal file
244
packages/hoppscotch-backend/prisma/schema.prisma
Normal file
@@ -0,0 +1,244 @@
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
binaryTargets = ["native", "debian-openssl-1.1.x", "debian-openssl-3.0.x"]
|
||||
}
|
||||
|
||||
model Team {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
members TeamMember[]
|
||||
TeamInvitation TeamInvitation[]
|
||||
TeamCollection TeamCollection[]
|
||||
TeamRequest TeamRequest[]
|
||||
TeamEnvironment TeamEnvironment[]
|
||||
}
|
||||
|
||||
model TeamMember {
|
||||
id String @id @default(uuid()) // Membership ID
|
||||
role TeamMemberRole
|
||||
userUid String
|
||||
teamID String
|
||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([teamID, userUid])
|
||||
}
|
||||
|
||||
model TeamInvitation {
|
||||
id String @id @default(cuid())
|
||||
teamID String
|
||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
||||
creatorUid String
|
||||
inviteeEmail String
|
||||
inviteeRole TeamMemberRole
|
||||
|
||||
@@unique([teamID, inviteeEmail])
|
||||
@@index([teamID])
|
||||
}
|
||||
|
||||
model TeamCollection {
|
||||
id String @id @default(cuid())
|
||||
parentID String?
|
||||
data Json?
|
||||
parent TeamCollection? @relation("TeamCollectionChildParent", fields: [parentID], references: [id])
|
||||
children TeamCollection[] @relation("TeamCollectionChildParent")
|
||||
requests TeamRequest[]
|
||||
teamID String
|
||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
||||
title String
|
||||
orderIndex Int
|
||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
||||
}
|
||||
|
||||
model TeamRequest {
|
||||
id String @id @default(cuid())
|
||||
collectionID String
|
||||
collection TeamCollection @relation(fields: [collectionID], references: [id], onDelete: Cascade)
|
||||
teamID String
|
||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
||||
title String
|
||||
request Json
|
||||
orderIndex Int
|
||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
||||
}
|
||||
|
||||
model Shortcode {
|
||||
id String @id @unique
|
||||
request Json
|
||||
embedProperties Json?
|
||||
creatorUid String?
|
||||
User User? @relation(fields: [creatorUid], references: [uid])
|
||||
createdOn DateTime @default(now())
|
||||
updatedOn DateTime @default(now()) @updatedAt
|
||||
|
||||
@@unique(fields: [id, creatorUid], name: "creator_uid_shortcode_unique")
|
||||
}
|
||||
|
||||
model TeamEnvironment {
|
||||
id String @id @default(cuid())
|
||||
teamID String
|
||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
variables Json
|
||||
}
|
||||
|
||||
model User {
|
||||
uid String @id @default(cuid())
|
||||
displayName String?
|
||||
email String? @unique
|
||||
photoURL String?
|
||||
isAdmin Boolean @default(false)
|
||||
refreshToken String?
|
||||
providerAccounts Account[]
|
||||
VerificationToken VerificationToken[]
|
||||
settings UserSettings?
|
||||
UserHistory UserHistory[]
|
||||
UserEnvironments UserEnvironment[]
|
||||
userCollections UserCollection[]
|
||||
userRequests UserRequest[]
|
||||
currentRESTSession Json?
|
||||
currentGQLSession Json?
|
||||
lastLoggedOn DateTime? @db.Timestamp(3)
|
||||
lastActiveOn DateTime? @db.Timestamp(3)
|
||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||
invitedUsers InvitedUsers[]
|
||||
shortcodes Shortcode[]
|
||||
personalAccessTokens PersonalAccessToken[]
|
||||
}
|
||||
|
||||
model Account {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [uid], onDelete: Cascade)
|
||||
provider String
|
||||
providerAccountId String
|
||||
providerRefreshToken String?
|
||||
providerAccessToken String?
|
||||
providerScope String?
|
||||
loggedIn DateTime @default(now()) @db.Timestamp(3)
|
||||
|
||||
@@unique(fields: [provider, providerAccountId], name: "verifyProviderAccount")
|
||||
}
|
||||
|
||||
model VerificationToken {
|
||||
deviceIdentifier String
|
||||
token String @unique @default(cuid())
|
||||
userUid String
|
||||
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
|
||||
expiresOn DateTime @db.Timestamp(3)
|
||||
|
||||
@@unique(fields: [deviceIdentifier, token], name: "passwordless_deviceIdentifier_tokens")
|
||||
}
|
||||
|
||||
model UserSettings {
|
||||
id String @id @default(cuid())
|
||||
userUid String @unique
|
||||
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
|
||||
properties Json
|
||||
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
||||
}
|
||||
|
||||
model UserHistory {
|
||||
id String @id @default(cuid())
|
||||
userUid String
|
||||
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
|
||||
reqType ReqType
|
||||
request Json
|
||||
responseMetadata Json
|
||||
isStarred Boolean
|
||||
executedOn DateTime @default(now()) @db.Timestamp(3)
|
||||
}
|
||||
|
||||
enum ReqType {
|
||||
REST
|
||||
GQL
|
||||
}
|
||||
|
||||
model UserEnvironment {
|
||||
id String @id @default(cuid())
|
||||
userUid String
|
||||
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
|
||||
name String?
|
||||
variables Json
|
||||
isGlobal Boolean
|
||||
}
|
||||
|
||||
model InvitedUsers {
|
||||
adminUid String
|
||||
user User @relation(fields: [adminUid], references: [uid], onDelete: Cascade)
|
||||
adminEmail String
|
||||
inviteeEmail String @unique
|
||||
invitedOn DateTime @default(now()) @db.Timestamp(3)
|
||||
}
|
||||
|
||||
model UserRequest {
|
||||
id String @id @default(cuid())
|
||||
userCollection UserCollection @relation(fields: [collectionID], references: [id])
|
||||
collectionID String
|
||||
userUid String
|
||||
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
|
||||
title String
|
||||
request Json
|
||||
type ReqType
|
||||
orderIndex Int
|
||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
||||
}
|
||||
|
||||
model UserCollection {
|
||||
id String @id @default(cuid())
|
||||
parentID String?
|
||||
parent UserCollection? @relation("ParentUserCollection", fields: [parentID], references: [id], onDelete: Cascade)
|
||||
children UserCollection[] @relation("ParentUserCollection")
|
||||
requests UserRequest[]
|
||||
userUid String
|
||||
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
|
||||
title String
|
||||
data Json?
|
||||
orderIndex Int
|
||||
type ReqType
|
||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
||||
}
|
||||
|
||||
enum TeamMemberRole {
|
||||
OWNER
|
||||
VIEWER
|
||||
EDITOR
|
||||
}
|
||||
|
||||
model InfraConfig {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
value String?
|
||||
active Boolean @default(true) // Use case: Let's say, Admin wants to disable Google SSO, but doesn't want to delete the config
|
||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
||||
}
|
||||
|
||||
model PersonalAccessToken {
|
||||
id String @id @default(cuid())
|
||||
userUid String
|
||||
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
|
||||
label String
|
||||
token String @unique @default(uuid())
|
||||
expiresOn DateTime? @db.Timestamp(3)
|
||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
||||
}
|
||||
|
||||
model InfraToken {
|
||||
id String @id @default(cuid())
|
||||
creatorUid String
|
||||
label String
|
||||
token String @unique @default(uuid())
|
||||
expiresOn DateTime? @db.Timestamp(3)
|
||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||
updatedOn DateTime @default(now()) @db.Timestamp(3)
|
||||
}
|
||||
66
packages/hoppscotch-backend/prod_run.mjs
Normal file
66
packages/hoppscotch-backend/prod_run.mjs
Normal file
@@ -0,0 +1,66 @@
|
||||
#!/usr/local/bin/node
|
||||
// @ts-check
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import process from 'process';
|
||||
|
||||
function runChildProcessWithPrefix(command, args, prefix) {
|
||||
const childProcess = spawn(command, args);
|
||||
|
||||
childProcess.stdout.on('data', (data) => {
|
||||
const output = data.toString().trim().split('\n');
|
||||
output.forEach((line) => {
|
||||
console.log(`${prefix} | ${line}`);
|
||||
});
|
||||
});
|
||||
|
||||
childProcess.stderr.on('data', (data) => {
|
||||
const error = data.toString().trim().split('\n');
|
||||
error.forEach((line) => {
|
||||
console.error(`${prefix} | ${line}`);
|
||||
});
|
||||
});
|
||||
|
||||
childProcess.on('close', (code) => {
|
||||
console.log(`${prefix} Child process exited with code ${code}`);
|
||||
});
|
||||
|
||||
childProcess.on('error', (stuff) => {
|
||||
console.error('error');
|
||||
console.error(stuff);
|
||||
});
|
||||
|
||||
return childProcess;
|
||||
}
|
||||
|
||||
const caddyProcess = runChildProcessWithPrefix(
|
||||
'caddy',
|
||||
['run', '--config', '/etc/caddy/backend.Caddyfile', '--adapter', 'caddyfile'],
|
||||
'App/Admin Dashboard Caddy',
|
||||
);
|
||||
const backendProcess = runChildProcessWithPrefix(
|
||||
'pnpm',
|
||||
['run', 'start:prod'],
|
||||
'Backend Server',
|
||||
);
|
||||
|
||||
caddyProcess.on('exit', (code) => {
|
||||
console.log(`Exiting process because Caddy Server exited with code ${code}`);
|
||||
process.exit(code);
|
||||
});
|
||||
|
||||
backendProcess.on('exit', (code) => {
|
||||
console.log(
|
||||
`Exiting process because Backend Server exited with code ${code}`,
|
||||
);
|
||||
process.exit(code);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('SIGINT received, exiting...');
|
||||
|
||||
caddyProcess.kill('SIGINT');
|
||||
backendProcess.kill('SIGINT');
|
||||
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { AccessTokenService } from './access-token.service';
|
||||
import { CreateAccessTokenDto } from './dto/create-access-token.dto';
|
||||
import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import { throwHTTPErr } from 'src/utils';
|
||||
import { GqlUser } from 'src/decorators/gql-user.decorator';
|
||||
import { AuthUser } from 'src/types/AuthUser';
|
||||
import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard';
|
||||
import { PATAuthGuard } from 'src/guards/rest-pat-auth.guard';
|
||||
import { AccessTokenInterceptor } from 'src/interceptors/access-token.interceptor';
|
||||
import { TeamEnvironmentsService } from 'src/team-environments/team-environments.service';
|
||||
import { TeamCollectionService } from 'src/team-collection/team-collection.service';
|
||||
import { ACCESS_TOKENS_INVALID_DATA_ID } from 'src/errors';
|
||||
import { createCLIErrorResponse } from './helper';
|
||||
|
||||
@UseGuards(ThrottlerBehindProxyGuard)
|
||||
@Controller({ path: 'access-tokens', version: '1' })
|
||||
export class AccessTokenController {
|
||||
constructor(
|
||||
private readonly accessTokenService: AccessTokenService,
|
||||
private readonly teamCollectionService: TeamCollectionService,
|
||||
private readonly teamEnvironmentsService: TeamEnvironmentsService,
|
||||
) {}
|
||||
|
||||
@Post('create')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async createPAT(
|
||||
@GqlUser() user: AuthUser,
|
||||
@Body() createAccessTokenDto: CreateAccessTokenDto,
|
||||
) {
|
||||
const result = await this.accessTokenService.createPAT(
|
||||
createAccessTokenDto,
|
||||
user,
|
||||
);
|
||||
if (E.isLeft(result)) throwHTTPErr(result.left);
|
||||
return result.right;
|
||||
}
|
||||
|
||||
@Delete('revoke')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async deletePAT(@Query('id') id: string) {
|
||||
const result = await this.accessTokenService.deletePAT(id);
|
||||
|
||||
if (E.isLeft(result)) throwHTTPErr(result.left);
|
||||
return result.right;
|
||||
}
|
||||
|
||||
@Get('list')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async listAllUserPAT(
|
||||
@GqlUser() user: AuthUser,
|
||||
@Query('offset', ParseIntPipe) offset: number,
|
||||
@Query('limit', ParseIntPipe) limit: number,
|
||||
) {
|
||||
return await this.accessTokenService.listAllUserPAT(
|
||||
user.uid,
|
||||
offset,
|
||||
limit,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('collection/:id')
|
||||
@UseGuards(PATAuthGuard)
|
||||
@UseInterceptors(AccessTokenInterceptor)
|
||||
async fetchCollection(@GqlUser() user: AuthUser, @Param('id') id: string) {
|
||||
const res = await this.teamCollectionService.getCollectionForCLI(
|
||||
id,
|
||||
user.uid,
|
||||
);
|
||||
|
||||
if (E.isLeft(res))
|
||||
throw new BadRequestException(
|
||||
createCLIErrorResponse(ACCESS_TOKENS_INVALID_DATA_ID),
|
||||
);
|
||||
return res.right;
|
||||
}
|
||||
|
||||
@Get('environment/:id')
|
||||
@UseGuards(PATAuthGuard)
|
||||
@UseInterceptors(AccessTokenInterceptor)
|
||||
async fetchEnvironment(@GqlUser() user: AuthUser, @Param('id') id: string) {
|
||||
const res = await this.teamEnvironmentsService.getTeamEnvironmentForCLI(
|
||||
id,
|
||||
user.uid,
|
||||
);
|
||||
|
||||
if (E.isLeft(res))
|
||||
throw new BadRequestException(
|
||||
createCLIErrorResponse(ACCESS_TOKENS_INVALID_DATA_ID),
|
||||
);
|
||||
return res.right;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AccessTokenController } from './access-token.controller';
|
||||
import { PrismaModule } from 'src/prisma/prisma.module';
|
||||
import { AccessTokenService } from './access-token.service';
|
||||
import { TeamCollectionModule } from 'src/team-collection/team-collection.module';
|
||||
import { TeamEnvironmentsModule } from 'src/team-environments/team-environments.module';
|
||||
import { TeamModule } from 'src/team/team.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PrismaModule,
|
||||
TeamCollectionModule,
|
||||
TeamEnvironmentsModule,
|
||||
TeamModule,
|
||||
],
|
||||
controllers: [AccessTokenController],
|
||||
providers: [AccessTokenService],
|
||||
exports: [AccessTokenService],
|
||||
})
|
||||
export class AccessTokenModule {}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { AccessTokenService } from './access-token.service';
|
||||
import { mockDeep, mockReset } from 'jest-mock-extended';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import {
|
||||
ACCESS_TOKEN_EXPIRY_INVALID,
|
||||
ACCESS_TOKEN_LABEL_SHORT,
|
||||
ACCESS_TOKEN_NOT_FOUND,
|
||||
} from 'src/errors';
|
||||
import { AuthUser } from 'src/types/AuthUser';
|
||||
import { PersonalAccessToken } from '@prisma/client';
|
||||
import { AccessToken } from 'src/types/AccessToken';
|
||||
import { HttpStatus } from '@nestjs/common';
|
||||
|
||||
const mockPrisma = mockDeep<PrismaService>();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const accessTokenService = new AccessTokenService(mockPrisma);
|
||||
|
||||
const currentTime = new Date();
|
||||
|
||||
const user: AuthUser = {
|
||||
uid: '123344',
|
||||
email: 'dwight@dundermifflin.com',
|
||||
displayName: 'Dwight Schrute',
|
||||
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
|
||||
isAdmin: false,
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
createdOn: currentTime,
|
||||
currentGQLSession: {},
|
||||
currentRESTSession: {},
|
||||
lastLoggedOn: currentTime,
|
||||
lastActiveOn: currentTime,
|
||||
};
|
||||
|
||||
const PATCreatedOn = new Date();
|
||||
const expiryInDays = 7;
|
||||
const PATExpiresOn = new Date(
|
||||
PATCreatedOn.getTime() + expiryInDays * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
|
||||
const userAccessToken: PersonalAccessToken = {
|
||||
id: 'skfvhj8uvdfivb',
|
||||
userUid: user.uid,
|
||||
label: 'test',
|
||||
token: '0140e328-b187-4823-ae4b-ed4bec832ac2',
|
||||
expiresOn: PATExpiresOn,
|
||||
createdOn: PATCreatedOn,
|
||||
updatedOn: new Date(),
|
||||
};
|
||||
|
||||
const userAccessTokenCasted: AccessToken = {
|
||||
id: userAccessToken.id,
|
||||
label: userAccessToken.label,
|
||||
createdOn: userAccessToken.createdOn,
|
||||
lastUsedOn: userAccessToken.updatedOn,
|
||||
expiresOn: userAccessToken.expiresOn,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockReset(mockPrisma);
|
||||
});
|
||||
|
||||
describe('AccessTokenService', () => {
|
||||
describe('createPAT', () => {
|
||||
test('should throw ACCESS_TOKEN_LABEL_SHORT if label is too short', async () => {
|
||||
const result = await accessTokenService.createPAT(
|
||||
{
|
||||
label: 'a',
|
||||
expiryInDays: 7,
|
||||
},
|
||||
user,
|
||||
);
|
||||
expect(result).toEqualLeft({
|
||||
message: ACCESS_TOKEN_LABEL_SHORT,
|
||||
statusCode: HttpStatus.BAD_REQUEST,
|
||||
});
|
||||
});
|
||||
|
||||
test('should throw ACCESS_TOKEN_EXPIRY_INVALID if expiry date is invalid', async () => {
|
||||
const result = await accessTokenService.createPAT(
|
||||
{
|
||||
label: 'test',
|
||||
expiryInDays: 9,
|
||||
},
|
||||
user,
|
||||
);
|
||||
expect(result).toEqualLeft({
|
||||
message: ACCESS_TOKEN_EXPIRY_INVALID,
|
||||
statusCode: HttpStatus.BAD_REQUEST,
|
||||
});
|
||||
});
|
||||
|
||||
test('should successfully create a new Access Token', async () => {
|
||||
mockPrisma.personalAccessToken.create.mockResolvedValueOnce(
|
||||
userAccessToken,
|
||||
);
|
||||
|
||||
const result = await accessTokenService.createPAT(
|
||||
{
|
||||
label: userAccessToken.label,
|
||||
expiryInDays,
|
||||
},
|
||||
user,
|
||||
);
|
||||
expect(result).toEqualRight({
|
||||
token: `pat-${userAccessToken.token}`,
|
||||
info: userAccessTokenCasted,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deletePAT', () => {
|
||||
test('should throw ACCESS_TOKEN_NOT_FOUND if Access Token is not found', async () => {
|
||||
mockPrisma.personalAccessToken.delete.mockRejectedValueOnce(
|
||||
'RecordNotFound',
|
||||
);
|
||||
|
||||
const result = await accessTokenService.deletePAT(userAccessToken.id);
|
||||
expect(result).toEqualLeft({
|
||||
message: ACCESS_TOKEN_NOT_FOUND,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
});
|
||||
|
||||
test('should successfully delete a new Access Token', async () => {
|
||||
mockPrisma.personalAccessToken.delete.mockResolvedValueOnce(
|
||||
userAccessToken,
|
||||
);
|
||||
|
||||
const result = await accessTokenService.deletePAT(userAccessToken.id);
|
||||
expect(result).toEqualRight(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listAllUserPAT', () => {
|
||||
test('should successfully return a list of user Access Tokens', async () => {
|
||||
mockPrisma.personalAccessToken.findMany.mockResolvedValueOnce([
|
||||
userAccessToken,
|
||||
]);
|
||||
|
||||
const result = await accessTokenService.listAllUserPAT(user.uid, 0, 10);
|
||||
expect(result).toEqual([userAccessTokenCasted]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserPAT', () => {
|
||||
test('should throw ACCESS_TOKEN_NOT_FOUND if Access Token is not found', async () => {
|
||||
mockPrisma.personalAccessToken.findUniqueOrThrow.mockRejectedValueOnce(
|
||||
'NotFoundError',
|
||||
);
|
||||
|
||||
const result = await accessTokenService.getUserPAT(userAccessToken.token);
|
||||
expect(result).toEqualLeft(ACCESS_TOKEN_NOT_FOUND);
|
||||
});
|
||||
|
||||
test('should successfully return a user Access Tokens', async () => {
|
||||
mockPrisma.personalAccessToken.findUniqueOrThrow.mockResolvedValueOnce({
|
||||
...userAccessToken,
|
||||
user,
|
||||
} as any);
|
||||
|
||||
const result = await accessTokenService.getUserPAT(
|
||||
`pat-${userAccessToken.token}`,
|
||||
);
|
||||
expect(result).toEqualRight({
|
||||
user,
|
||||
...userAccessToken,
|
||||
} as any);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateLastUsedforPAT', () => {
|
||||
test('should throw ACCESS_TOKEN_NOT_FOUND if Access Token is not found', async () => {
|
||||
mockPrisma.personalAccessToken.update.mockRejectedValueOnce(
|
||||
'RecordNotFound',
|
||||
);
|
||||
|
||||
const result = await accessTokenService.updateLastUsedForPAT(
|
||||
userAccessToken.token,
|
||||
);
|
||||
expect(result).toEqualLeft(ACCESS_TOKEN_NOT_FOUND);
|
||||
});
|
||||
|
||||
test('should successfully update lastUsedOn for a user Access Tokens', async () => {
|
||||
mockPrisma.personalAccessToken.update.mockResolvedValueOnce(
|
||||
userAccessToken,
|
||||
);
|
||||
|
||||
const result = await accessTokenService.updateLastUsedForPAT(
|
||||
`pat-${userAccessToken.token}`,
|
||||
);
|
||||
expect(result).toEqualRight(userAccessTokenCasted);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { CreateAccessTokenDto } from './dto/create-access-token.dto';
|
||||
import { AuthUser } from 'src/types/AuthUser';
|
||||
import { calculateExpirationDate, isValidLength } from 'src/utils';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import {
|
||||
ACCESS_TOKEN_EXPIRY_INVALID,
|
||||
ACCESS_TOKEN_LABEL_SHORT,
|
||||
ACCESS_TOKEN_NOT_FOUND,
|
||||
} from 'src/errors';
|
||||
import { CreateAccessTokenResponse } from './helper';
|
||||
import { PersonalAccessToken } from '@prisma/client';
|
||||
import { AccessToken } from 'src/types/AccessToken';
|
||||
@Injectable()
|
||||
export class AccessTokenService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
TITLE_LENGTH = 3;
|
||||
VALID_TOKEN_DURATIONS = [7, 30, 60, 90];
|
||||
TOKEN_PREFIX = 'pat-';
|
||||
|
||||
/**
|
||||
* Validate the expiration date of the token
|
||||
*
|
||||
* @param expiresOn Number of days the token is valid for
|
||||
* @returns Boolean indicating if the expiration date is valid
|
||||
*/
|
||||
private validateExpirationDate(expiresOn: null | number) {
|
||||
if (expiresOn === null || this.VALID_TOKEN_DURATIONS.includes(expiresOn))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Typecast a database PersonalAccessToken to a AccessToken model
|
||||
* @param token database PersonalAccessToken
|
||||
* @returns AccessToken model
|
||||
*/
|
||||
private cast(token: PersonalAccessToken): AccessToken {
|
||||
return <AccessToken>{
|
||||
id: token.id,
|
||||
label: token.label,
|
||||
createdOn: token.createdOn,
|
||||
expiresOn: token.expiresOn,
|
||||
lastUsedOn: token.updatedOn,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract UUID from the token
|
||||
*
|
||||
* @param token Personal Access Token
|
||||
* @returns UUID of the token
|
||||
*/
|
||||
private extractUUID(token): string | null {
|
||||
if (!token.startsWith(this.TOKEN_PREFIX)) return null;
|
||||
return token.slice(this.TOKEN_PREFIX.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Personal Access Token
|
||||
*
|
||||
* @param createAccessTokenDto DTO for creating a Personal Access Token
|
||||
* @param user AuthUser object
|
||||
* @returns Either of the created token or error message
|
||||
*/
|
||||
async createPAT(createAccessTokenDto: CreateAccessTokenDto, user: AuthUser) {
|
||||
const isTitleValid = isValidLength(
|
||||
createAccessTokenDto.label,
|
||||
this.TITLE_LENGTH,
|
||||
);
|
||||
if (!isTitleValid)
|
||||
return E.left({
|
||||
message: ACCESS_TOKEN_LABEL_SHORT,
|
||||
statusCode: HttpStatus.BAD_REQUEST,
|
||||
});
|
||||
|
||||
if (!this.validateExpirationDate(createAccessTokenDto.expiryInDays))
|
||||
return E.left({
|
||||
message: ACCESS_TOKEN_EXPIRY_INVALID,
|
||||
statusCode: HttpStatus.BAD_REQUEST,
|
||||
});
|
||||
|
||||
const createdPAT = await this.prisma.personalAccessToken.create({
|
||||
data: {
|
||||
userUid: user.uid,
|
||||
label: createAccessTokenDto.label,
|
||||
expiresOn: calculateExpirationDate(createAccessTokenDto.expiryInDays),
|
||||
},
|
||||
});
|
||||
|
||||
const res: CreateAccessTokenResponse = {
|
||||
token: `${this.TOKEN_PREFIX}${createdPAT.token}`,
|
||||
info: this.cast(createdPAT),
|
||||
};
|
||||
|
||||
return E.right(res);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a Personal Access Token
|
||||
*
|
||||
* @param accessTokenID ID of the Personal Access Token
|
||||
* @returns Either of true or error message
|
||||
*/
|
||||
async deletePAT(accessTokenID: string) {
|
||||
try {
|
||||
await this.prisma.personalAccessToken.delete({
|
||||
where: { id: accessTokenID },
|
||||
});
|
||||
return E.right(true);
|
||||
} catch {
|
||||
return E.left({
|
||||
message: ACCESS_TOKEN_NOT_FOUND,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all Personal Access Tokens of a user
|
||||
*
|
||||
* @param userUid UID of the user
|
||||
* @param offset Offset for pagination
|
||||
* @param limit Limit for pagination
|
||||
* @returns Either of the list of Personal Access Tokens or error message
|
||||
*/
|
||||
async listAllUserPAT(userUid: string, offset: number, limit: number) {
|
||||
const userPATs = await this.prisma.personalAccessToken.findMany({
|
||||
where: {
|
||||
userUid: userUid,
|
||||
},
|
||||
skip: offset,
|
||||
take: limit,
|
||||
orderBy: {
|
||||
createdOn: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
const userAccessTokenList = userPATs.map((pat) => this.cast(pat));
|
||||
|
||||
return userAccessTokenList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a Personal Access Token
|
||||
*
|
||||
* @param accessToken Personal Access Token
|
||||
* @returns Either of the Personal Access Token or error message
|
||||
*/
|
||||
async getUserPAT(accessToken: string) {
|
||||
const extractedToken = this.extractUUID(accessToken);
|
||||
if (!extractedToken) return E.left(ACCESS_TOKEN_NOT_FOUND);
|
||||
|
||||
try {
|
||||
const userPAT = await this.prisma.personalAccessToken.findUniqueOrThrow({
|
||||
where: { token: extractedToken },
|
||||
include: { user: true },
|
||||
});
|
||||
return E.right(userPAT);
|
||||
} catch {
|
||||
return E.left(ACCESS_TOKEN_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the last used date of a Personal Access Token
|
||||
*
|
||||
* @param token Personal Access Token
|
||||
* @returns Either of the updated Personal Access Token or error message
|
||||
*/
|
||||
async updateLastUsedForPAT(token: string) {
|
||||
const extractedToken = this.extractUUID(token);
|
||||
if (!extractedToken) return E.left(ACCESS_TOKEN_NOT_FOUND);
|
||||
|
||||
try {
|
||||
const updatedAccessToken = await this.prisma.personalAccessToken.update({
|
||||
where: { token: extractedToken },
|
||||
data: {
|
||||
updatedOn: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return E.right(this.cast(updatedAccessToken));
|
||||
} catch {
|
||||
return E.left(ACCESS_TOKEN_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Inputs to create a new PAT
|
||||
export class CreateAccessTokenDto {
|
||||
label: string;
|
||||
expiryInDays: number | null;
|
||||
}
|
||||
17
packages/hoppscotch-backend/src/access-token/helper.ts
Normal file
17
packages/hoppscotch-backend/src/access-token/helper.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { AccessToken } from 'src/types/AccessToken';
|
||||
|
||||
// Response type of PAT creation method
|
||||
export type CreateAccessTokenResponse = {
|
||||
token: string;
|
||||
info: AccessToken;
|
||||
};
|
||||
|
||||
// Response type of any error in PAT module
|
||||
export type CLIErrorResponse = {
|
||||
reason: string;
|
||||
};
|
||||
|
||||
// Return a CLIErrorResponse object
|
||||
export function createCLIErrorResponse(reason: string): CLIErrorResponse {
|
||||
return { reason };
|
||||
}
|
||||
9
packages/hoppscotch-backend/src/admin/admin.model.ts
Normal file
9
packages/hoppscotch-backend/src/admin/admin.model.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ObjectType, OmitType } from '@nestjs/graphql';
|
||||
import { User } from 'src/user/user.model';
|
||||
|
||||
@ObjectType()
|
||||
export class Admin extends OmitType(User, [
|
||||
'isAdmin',
|
||||
'currentRESTSession',
|
||||
'currentGQLSession',
|
||||
]) {}
|
||||
32
packages/hoppscotch-backend/src/admin/admin.module.ts
Normal file
32
packages/hoppscotch-backend/src/admin/admin.module.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AdminResolver } from './admin.resolver';
|
||||
import { AdminService } from './admin.service';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { PubSubModule } from '../pubsub/pubsub.module';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { TeamModule } from '../team/team.module';
|
||||
import { TeamInvitationModule } from '../team-invitation/team-invitation.module';
|
||||
import { TeamEnvironmentsModule } from '../team-environments/team-environments.module';
|
||||
import { TeamCollectionModule } from '../team-collection/team-collection.module';
|
||||
import { TeamRequestModule } from '../team-request/team-request.module';
|
||||
import { InfraResolver } from './infra.resolver';
|
||||
import { ShortcodeModule } from 'src/shortcode/shortcode.module';
|
||||
import { InfraConfigModule } from 'src/infra-config/infra-config.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PrismaModule,
|
||||
PubSubModule,
|
||||
UserModule,
|
||||
TeamModule,
|
||||
TeamInvitationModule,
|
||||
TeamEnvironmentsModule,
|
||||
TeamCollectionModule,
|
||||
TeamRequestModule,
|
||||
ShortcodeModule,
|
||||
InfraConfigModule,
|
||||
],
|
||||
providers: [InfraResolver, AdminResolver, AdminService],
|
||||
exports: [AdminService],
|
||||
})
|
||||
export class AdminModule {}
|
||||
374
packages/hoppscotch-backend/src/admin/admin.resolver.ts
Normal file
374
packages/hoppscotch-backend/src/admin/admin.resolver.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
import {
|
||||
Args,
|
||||
ID,
|
||||
Mutation,
|
||||
Parent,
|
||||
Query,
|
||||
ResolveField,
|
||||
Resolver,
|
||||
Subscription,
|
||||
} from '@nestjs/graphql';
|
||||
import { Admin } from './admin.model';
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { GqlAuthGuard } from '../guards/gql-auth.guard';
|
||||
import { GqlAdminGuard } from './guards/gql-admin.guard';
|
||||
import { GqlAdmin } from './decorators/gql-admin.decorator';
|
||||
import { AdminService } from './admin.service';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import { throwErr } from '../utils';
|
||||
import { AuthUser } from '../types/AuthUser';
|
||||
import { InvitedUser } from './invited-user.model';
|
||||
import { GqlUser } from '../decorators/gql-user.decorator';
|
||||
import { PubSubService } from '../pubsub/pubsub.service';
|
||||
import { Team, TeamMember } from '../team/team.model';
|
||||
import {
|
||||
AddUserToTeamArgs,
|
||||
ChangeUserRoleInTeamArgs,
|
||||
} from './input-types.args';
|
||||
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
|
||||
import { SkipThrottle } from '@nestjs/throttler';
|
||||
import { UserDeletionResult } from 'src/user/user.model';
|
||||
|
||||
@UseGuards(GqlThrottlerGuard)
|
||||
@Resolver(() => Admin)
|
||||
export class AdminResolver {
|
||||
constructor(
|
||||
private adminService: AdminService,
|
||||
private readonly pubsub: PubSubService,
|
||||
) {}
|
||||
|
||||
/* Query */
|
||||
|
||||
@Query(() => Admin, {
|
||||
description: 'Gives details of the admin executing this query',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
admin(@GqlAdmin() admin: Admin) {
|
||||
return admin;
|
||||
}
|
||||
|
||||
/* Mutations */
|
||||
|
||||
@Mutation(() => InvitedUser, {
|
||||
description: 'Invite a user to the infra using email',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async inviteNewUser(
|
||||
@GqlUser() adminUser: AuthUser,
|
||||
@Args({
|
||||
name: 'inviteeEmail',
|
||||
description: 'invitee email',
|
||||
})
|
||||
inviteeEmail: string,
|
||||
): Promise<InvitedUser> {
|
||||
const invitedUser = await this.adminService.inviteUserToSignInViaEmail(
|
||||
adminUser.uid,
|
||||
adminUser.email,
|
||||
inviteeEmail,
|
||||
);
|
||||
if (E.isLeft(invitedUser)) throwErr(invitedUser.left);
|
||||
return invitedUser.right;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Revoke a user invites by invitee emails',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async revokeUserInvitationsByAdmin(
|
||||
@Args({
|
||||
name: 'inviteeEmails',
|
||||
description: 'Invitee Emails',
|
||||
type: () => [String],
|
||||
})
|
||||
inviteeEmails: string[],
|
||||
): Promise<boolean> {
|
||||
const invite = await this.adminService.revokeUserInvitations(inviteeEmails);
|
||||
if (E.isLeft(invite)) throwErr(invite.left);
|
||||
return invite.right;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Delete an user account from infra',
|
||||
deprecationReason: 'Use removeUsersByAdmin instead',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async removeUserByAdmin(
|
||||
@Args({
|
||||
name: 'userUID',
|
||||
description: 'users UID',
|
||||
type: () => ID,
|
||||
})
|
||||
userUID: string,
|
||||
): Promise<boolean> {
|
||||
const removedUser = await this.adminService.removeUserAccount(userUID);
|
||||
if (E.isLeft(removedUser)) throwErr(removedUser.left);
|
||||
return removedUser.right;
|
||||
}
|
||||
|
||||
@Mutation(() => [UserDeletionResult], {
|
||||
description: 'Delete user accounts from infra',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async removeUsersByAdmin(
|
||||
@Args({
|
||||
name: 'userUIDs',
|
||||
description: 'users UID',
|
||||
type: () => [ID],
|
||||
})
|
||||
userUIDs: string[],
|
||||
): Promise<UserDeletionResult[]> {
|
||||
const deletionResults = await this.adminService.removeUserAccounts(
|
||||
userUIDs,
|
||||
);
|
||||
if (E.isLeft(deletionResults)) throwErr(deletionResults.left);
|
||||
return deletionResults.right;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Make user an admin',
|
||||
deprecationReason: 'Use makeUsersAdmin instead',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async makeUserAdmin(
|
||||
@Args({
|
||||
name: 'userUID',
|
||||
description: 'users UID',
|
||||
type: () => ID,
|
||||
})
|
||||
userUID: string,
|
||||
): Promise<boolean> {
|
||||
const admin = await this.adminService.makeUserAdmin(userUID);
|
||||
if (E.isLeft(admin)) throwErr(admin.left);
|
||||
return admin.right;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Make users an admin',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async makeUsersAdmin(
|
||||
@Args({
|
||||
name: 'userUIDs',
|
||||
description: 'users UID',
|
||||
type: () => [ID],
|
||||
})
|
||||
userUIDs: string[],
|
||||
): Promise<boolean> {
|
||||
const isUpdated = await this.adminService.makeUsersAdmin(userUIDs);
|
||||
if (E.isLeft(isUpdated)) throwErr(isUpdated.left);
|
||||
return isUpdated.right;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Update user display name',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async updateUserDisplayNameByAdmin(
|
||||
@Args({
|
||||
name: 'userUID',
|
||||
description: 'users UID',
|
||||
type: () => ID,
|
||||
})
|
||||
userUID: string,
|
||||
@Args({
|
||||
name: 'displayName',
|
||||
description: 'users display name',
|
||||
})
|
||||
displayName: string,
|
||||
): Promise<boolean> {
|
||||
const isUpdated = await this.adminService.updateUserDisplayName(
|
||||
userUID,
|
||||
displayName,
|
||||
);
|
||||
if (E.isLeft(isUpdated)) throwErr(isUpdated.left);
|
||||
return isUpdated.right;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Remove user as admin',
|
||||
deprecationReason: 'Use demoteUsersByAdmin instead',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async removeUserAsAdmin(
|
||||
@Args({
|
||||
name: 'userUID',
|
||||
description: 'users UID',
|
||||
type: () => ID,
|
||||
})
|
||||
userUID: string,
|
||||
): Promise<boolean> {
|
||||
const admin = await this.adminService.removeUserAsAdmin(userUID);
|
||||
if (E.isLeft(admin)) throwErr(admin.left);
|
||||
return admin.right;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Remove users as admin',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async demoteUsersByAdmin(
|
||||
@Args({
|
||||
name: 'userUIDs',
|
||||
description: 'users UID',
|
||||
type: () => [ID],
|
||||
})
|
||||
userUIDs: string[],
|
||||
): Promise<boolean> {
|
||||
const isUpdated = await this.adminService.demoteUsersByAdmin(userUIDs);
|
||||
if (E.isLeft(isUpdated)) throwErr(isUpdated.left);
|
||||
return isUpdated.right;
|
||||
}
|
||||
|
||||
@Mutation(() => Team, {
|
||||
description:
|
||||
'Create a new team by providing the user uid to nominate as Team owner',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async createTeamByAdmin(
|
||||
@GqlAdmin() adminUser: Admin,
|
||||
@Args({
|
||||
name: 'userUid',
|
||||
description: 'users uid to make team owner',
|
||||
type: () => ID,
|
||||
})
|
||||
userUid: string,
|
||||
@Args({ name: 'name', description: 'Displayed name of the team' })
|
||||
name: string,
|
||||
): Promise<Team> {
|
||||
const createdTeam = await this.adminService.createATeam(userUid, name);
|
||||
if (E.isLeft(createdTeam)) throwErr(createdTeam.left);
|
||||
return createdTeam.right;
|
||||
}
|
||||
@Mutation(() => TeamMember, {
|
||||
description: 'Change the role of a user in a team',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async changeUserRoleInTeamByAdmin(
|
||||
@GqlAdmin() adminUser: Admin,
|
||||
@Args() args: ChangeUserRoleInTeamArgs,
|
||||
): Promise<TeamMember> {
|
||||
const updatedRole = await this.adminService.changeRoleOfUserTeam(
|
||||
args.userUID,
|
||||
args.teamID,
|
||||
args.newRole,
|
||||
);
|
||||
if (E.isLeft(updatedRole)) throwErr(updatedRole.left);
|
||||
return updatedRole.right;
|
||||
}
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Remove the user from a team',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async removeUserFromTeamByAdmin(
|
||||
@GqlAdmin() adminUser: Admin,
|
||||
@Args({
|
||||
name: 'userUid',
|
||||
description: 'users UID',
|
||||
type: () => ID,
|
||||
})
|
||||
userUid: string,
|
||||
@Args({
|
||||
name: 'teamID',
|
||||
description: 'team ID',
|
||||
type: () => ID,
|
||||
})
|
||||
teamID: string,
|
||||
): Promise<boolean> {
|
||||
const removedUser = await this.adminService.removeUserFromTeam(
|
||||
userUid,
|
||||
teamID,
|
||||
);
|
||||
if (E.isLeft(removedUser)) throwErr(removedUser.left);
|
||||
return removedUser.right;
|
||||
}
|
||||
@Mutation(() => TeamMember, {
|
||||
description: 'Add a user to a team with email and team member role',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async addUserToTeamByAdmin(
|
||||
@GqlAdmin() adminUser: Admin,
|
||||
@Args() args: AddUserToTeamArgs,
|
||||
): Promise<TeamMember> {
|
||||
const addedUser = await this.adminService.addUserToTeam(
|
||||
args.teamID,
|
||||
args.userEmail,
|
||||
args.role,
|
||||
);
|
||||
if (E.isLeft(addedUser)) throwErr(addedUser.left);
|
||||
return addedUser.right;
|
||||
}
|
||||
|
||||
@Mutation(() => Team, {
|
||||
description: 'Change a team name',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async renameTeamByAdmin(
|
||||
@GqlAdmin() adminUser: Admin,
|
||||
@Args({ name: 'teamID', description: 'ID of the team', type: () => ID })
|
||||
teamID: string,
|
||||
@Args({ name: 'newName', description: 'The updated name of the team' })
|
||||
newName: string,
|
||||
): Promise<Team> {
|
||||
const renamedTeam = await this.adminService.renameATeam(teamID, newName);
|
||||
if (E.isLeft(renamedTeam)) throwErr(renamedTeam.left);
|
||||
return renamedTeam.right;
|
||||
}
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Delete a team',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async deleteTeamByAdmin(
|
||||
@Args({ name: 'teamID', description: 'ID of the team', type: () => ID })
|
||||
teamID: string,
|
||||
): Promise<boolean> {
|
||||
const deletedTeam = await this.adminService.deleteATeam(teamID);
|
||||
if (E.isLeft(deletedTeam)) throwErr(deletedTeam.left);
|
||||
return deletedTeam.right;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Revoke a team Invite by Invite ID',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async revokeTeamInviteByAdmin(
|
||||
@Args({
|
||||
name: 'inviteID',
|
||||
description: 'Team Invite ID',
|
||||
type: () => ID,
|
||||
})
|
||||
inviteID: string,
|
||||
): Promise<boolean> {
|
||||
const invite = await this.adminService.revokeTeamInviteByID(inviteID);
|
||||
if (E.isLeft(invite)) throwErr(invite.left);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Revoke Shortcode by ID',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async revokeShortcodeByAdmin(
|
||||
@Args({
|
||||
name: 'code',
|
||||
description: 'The shortcode to delete',
|
||||
type: () => ID,
|
||||
})
|
||||
code: string,
|
||||
): Promise<boolean> {
|
||||
const res = await this.adminService.deleteShortcode(code);
|
||||
if (E.isLeft(res)) throwErr(res.left);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Subscriptions */
|
||||
|
||||
@Subscription(() => InvitedUser, {
|
||||
description: 'Listen for User Invitation',
|
||||
resolve: (value) => value,
|
||||
})
|
||||
@SkipThrottle()
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
userInvited(@GqlUser() admin: AuthUser) {
|
||||
return this.pubsub.asyncIterator(`admin/${admin.uid}/invited`);
|
||||
}
|
||||
}
|
||||
295
packages/hoppscotch-backend/src/admin/admin.service.spec.ts
Normal file
295
packages/hoppscotch-backend/src/admin/admin.service.spec.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import { AdminService } from './admin.service';
|
||||
import { PubSubService } from '../pubsub/pubsub.service';
|
||||
import { mockDeep } from 'jest-mock-extended';
|
||||
import { InvitedUsers, User as DbUser } from '@prisma/client';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { TeamService } from '../team/team.service';
|
||||
import { TeamEnvironmentsService } from '../team-environments/team-environments.service';
|
||||
import { TeamRequestService } from '../team-request/team-request.service';
|
||||
import { TeamInvitationService } from '../team-invitation/team-invitation.service';
|
||||
import { TeamCollectionService } from '../team-collection/team-collection.service';
|
||||
import { MailerService } from '../mailer/mailer.service';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import {
|
||||
DUPLICATE_EMAIL,
|
||||
INVALID_EMAIL,
|
||||
ONLY_ONE_ADMIN_ACCOUNT,
|
||||
USER_ALREADY_INVITED,
|
||||
USER_INVITATION_DELETION_FAILED,
|
||||
USER_NOT_FOUND,
|
||||
} from '../errors';
|
||||
import { ShortcodeService } from 'src/shortcode/shortcode.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { OffsetPaginationArgs } from 'src/types/input-types.args';
|
||||
import * as E from 'fp-ts/Either';
|
||||
|
||||
const mockPrisma = mockDeep<PrismaService>();
|
||||
const mockPubSub = mockDeep<PubSubService>();
|
||||
const mockUserService = mockDeep<UserService>();
|
||||
const mockTeamService = mockDeep<TeamService>();
|
||||
const mockTeamEnvironmentsService = mockDeep<TeamEnvironmentsService>();
|
||||
const mockTeamRequestService = mockDeep<TeamRequestService>();
|
||||
const mockTeamInvitationService = mockDeep<TeamInvitationService>();
|
||||
const mockTeamCollectionService = mockDeep<TeamCollectionService>();
|
||||
const mockMailerService = mockDeep<MailerService>();
|
||||
const mockShortcodeService = mockDeep<ShortcodeService>();
|
||||
const mockConfigService = mockDeep<ConfigService>();
|
||||
|
||||
const adminService = new AdminService(
|
||||
mockUserService,
|
||||
mockTeamService,
|
||||
mockTeamCollectionService,
|
||||
mockTeamRequestService,
|
||||
mockTeamEnvironmentsService,
|
||||
mockTeamInvitationService,
|
||||
mockPubSub as any,
|
||||
mockPrisma as any,
|
||||
mockMailerService,
|
||||
mockShortcodeService,
|
||||
mockConfigService,
|
||||
);
|
||||
|
||||
const invitedUsers: InvitedUsers[] = [
|
||||
{
|
||||
adminUid: 'uid1',
|
||||
adminEmail: 'admin1@example.com',
|
||||
inviteeEmail: 'i@example.com',
|
||||
invitedOn: new Date(),
|
||||
},
|
||||
{
|
||||
adminUid: 'uid2',
|
||||
adminEmail: 'admin2@example.com',
|
||||
inviteeEmail: 'u@example.com',
|
||||
invitedOn: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
const dbAdminUsers: DbUser[] = [
|
||||
{
|
||||
uid: 'uid 1',
|
||||
displayName: 'displayName',
|
||||
email: 'email@email.com',
|
||||
photoURL: 'photoURL',
|
||||
isAdmin: true,
|
||||
refreshToken: 'refreshToken',
|
||||
currentRESTSession: '',
|
||||
currentGQLSession: '',
|
||||
lastLoggedOn: new Date(),
|
||||
lastActiveOn: new Date(),
|
||||
createdOn: new Date(),
|
||||
},
|
||||
{
|
||||
uid: 'uid 2',
|
||||
displayName: 'displayName',
|
||||
email: 'email@email.com',
|
||||
photoURL: 'photoURL',
|
||||
isAdmin: true,
|
||||
refreshToken: 'refreshToken',
|
||||
currentRESTSession: '',
|
||||
currentGQLSession: '',
|
||||
lastLoggedOn: new Date(),
|
||||
lastActiveOn: new Date(),
|
||||
createdOn: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
describe('AdminService', () => {
|
||||
describe('fetchInvitedUsers', () => {
|
||||
test('should resolve right and apply pagination correctly', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
mockPrisma.user.findMany.mockResolvedValue([dbAdminUsers[0]]);
|
||||
// @ts-ignore
|
||||
mockPrisma.invitedUsers.findMany.mockResolvedValue(invitedUsers);
|
||||
|
||||
const paginationArgs: OffsetPaginationArgs = { take: 5, skip: 2 };
|
||||
const results = await adminService.fetchInvitedUsers(paginationArgs);
|
||||
|
||||
expect(mockPrisma.invitedUsers.findMany).toHaveBeenCalledWith({
|
||||
...paginationArgs,
|
||||
orderBy: {
|
||||
invitedOn: 'desc',
|
||||
},
|
||||
where: {
|
||||
NOT: {
|
||||
inviteeEmail: {
|
||||
in: [dbAdminUsers[0].email],
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
test('should resolve right and return an array of invited users', async () => {
|
||||
const paginationArgs: OffsetPaginationArgs = { take: 10, skip: 0 };
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
mockPrisma.user.findMany.mockResolvedValue([dbAdminUsers[0]]);
|
||||
// @ts-ignore
|
||||
mockPrisma.invitedUsers.findMany.mockResolvedValue(invitedUsers);
|
||||
|
||||
const results = await adminService.fetchInvitedUsers(paginationArgs);
|
||||
expect(results).toEqual(invitedUsers);
|
||||
});
|
||||
test('should resolve left and return an empty array if invited users not found', async () => {
|
||||
const paginationArgs: OffsetPaginationArgs = { take: 10, skip: 0 };
|
||||
|
||||
mockPrisma.invitedUsers.findMany.mockResolvedValue([]);
|
||||
|
||||
const results = await adminService.fetchInvitedUsers(paginationArgs);
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inviteUserToSignInViaEmail', () => {
|
||||
test('should resolve right and create a invited user', async () => {
|
||||
mockPrisma.invitedUsers.findFirst.mockResolvedValueOnce(null);
|
||||
mockPrisma.invitedUsers.create.mockResolvedValueOnce(invitedUsers[0]);
|
||||
const result = await adminService.inviteUserToSignInViaEmail(
|
||||
invitedUsers[0].adminUid,
|
||||
invitedUsers[0].adminEmail,
|
||||
invitedUsers[0].inviteeEmail,
|
||||
);
|
||||
expect(mockPrisma.invitedUsers.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
adminUid: invitedUsers[0].adminUid,
|
||||
adminEmail: invitedUsers[0].adminEmail,
|
||||
inviteeEmail: invitedUsers[0].inviteeEmail,
|
||||
},
|
||||
});
|
||||
return expect(result).toEqualRight(invitedUsers[0]);
|
||||
});
|
||||
test('should resolve right, create a invited user and publish a subscription', async () => {
|
||||
mockPrisma.invitedUsers.findFirst.mockResolvedValueOnce(null);
|
||||
mockPrisma.invitedUsers.create.mockResolvedValueOnce(invitedUsers[0]);
|
||||
await adminService.inviteUserToSignInViaEmail(
|
||||
invitedUsers[0].adminUid,
|
||||
invitedUsers[0].adminEmail,
|
||||
invitedUsers[0].inviteeEmail,
|
||||
);
|
||||
return expect(mockPubSub.publish).toHaveBeenCalledWith(
|
||||
`admin/${invitedUsers[0].adminUid}/invited`,
|
||||
invitedUsers[0],
|
||||
);
|
||||
});
|
||||
test('should resolve left and return an error when invalid invitee email is passed', async () => {
|
||||
const result = await adminService.inviteUserToSignInViaEmail(
|
||||
invitedUsers[0].adminUid,
|
||||
invitedUsers[0].adminEmail,
|
||||
'invalidemail',
|
||||
);
|
||||
return expect(result).toEqualLeft(INVALID_EMAIL);
|
||||
});
|
||||
test('should resolve left and return an error when user already invited', async () => {
|
||||
mockPrisma.invitedUsers.findFirst.mockResolvedValueOnce(invitedUsers[0]);
|
||||
const result = await adminService.inviteUserToSignInViaEmail(
|
||||
invitedUsers[0].adminUid,
|
||||
invitedUsers[0].adminEmail,
|
||||
invitedUsers[0].inviteeEmail,
|
||||
);
|
||||
return expect(result).toEqualLeft(USER_ALREADY_INVITED);
|
||||
});
|
||||
test('should resolve left and return an error when invitee and admin email is same', async () => {
|
||||
const result = await adminService.inviteUserToSignInViaEmail(
|
||||
invitedUsers[0].adminUid,
|
||||
invitedUsers[0].inviteeEmail,
|
||||
invitedUsers[0].inviteeEmail,
|
||||
);
|
||||
return expect(result).toEqualLeft(DUPLICATE_EMAIL);
|
||||
});
|
||||
});
|
||||
|
||||
describe('revokeUserInvitations', () => {
|
||||
test('should resolve left and return error if email not invited', async () => {
|
||||
mockPrisma.invitedUsers.deleteMany.mockRejectedValueOnce(
|
||||
'RecordNotFound',
|
||||
);
|
||||
|
||||
const result = await adminService.revokeUserInvitations([
|
||||
'test@gmail.com',
|
||||
]);
|
||||
|
||||
expect(result).toEqualLeft(USER_INVITATION_DELETION_FAILED);
|
||||
});
|
||||
|
||||
test('should resolve right and return deleted invitee email', async () => {
|
||||
const adminUid = 'adminUid';
|
||||
mockPrisma.invitedUsers.deleteMany.mockResolvedValueOnce({ count: 1 });
|
||||
|
||||
const result = await adminService.revokeUserInvitations([
|
||||
invitedUsers[0].inviteeEmail,
|
||||
]);
|
||||
|
||||
expect(mockPrisma.invitedUsers.deleteMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
inviteeEmail: {
|
||||
in: [invitedUsers[0].inviteeEmail],
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result).toEqualRight(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeUsersAsAdmin', () => {
|
||||
test('should resolve right and make admins to users', async () => {
|
||||
mockUserService.fetchAdminUsers.mockResolvedValueOnce(dbAdminUsers);
|
||||
mockUserService.removeUsersAsAdmin.mockResolvedValueOnce(E.right(true));
|
||||
|
||||
return expect(
|
||||
await adminService.demoteUsersByAdmin([dbAdminUsers[0].uid]),
|
||||
).toEqualRight(true);
|
||||
});
|
||||
|
||||
test('should resolve left and return error if only one admin in the infra', async () => {
|
||||
mockUserService.fetchAdminUsers.mockResolvedValueOnce(dbAdminUsers);
|
||||
mockUserService.removeUsersAsAdmin.mockResolvedValueOnce(E.right(true));
|
||||
|
||||
return expect(
|
||||
await adminService.demoteUsersByAdmin(
|
||||
dbAdminUsers.map((user) => user.uid),
|
||||
),
|
||||
).toEqualLeft(ONLY_ONE_ADMIN_ACCOUNT);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUsersCount', () => {
|
||||
test('should return count of all users in the organization', async () => {
|
||||
mockUserService.getUsersCount.mockResolvedValueOnce(10);
|
||||
|
||||
const result = await adminService.getUsersCount();
|
||||
expect(result).toEqual(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTeamsCount', () => {
|
||||
test('should return count of all teams in the organization', async () => {
|
||||
mockTeamService.getTeamsCount.mockResolvedValueOnce(10);
|
||||
|
||||
const result = await adminService.getTeamsCount();
|
||||
expect(result).toEqual(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTeamCollectionsCount', () => {
|
||||
test('should return count of all Team Collections in the organization', async () => {
|
||||
mockTeamCollectionService.getTeamCollectionsCount.mockResolvedValueOnce(
|
||||
10,
|
||||
);
|
||||
|
||||
const result = await adminService.getTeamCollectionsCount();
|
||||
expect(result).toEqual(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTeamRequestsCount', () => {
|
||||
test('should return count of all Team Collections in the organization', async () => {
|
||||
mockTeamRequestService.getTeamRequestsCount.mockResolvedValueOnce(10);
|
||||
|
||||
const result = await adminService.getTeamRequestsCount();
|
||||
expect(result).toEqual(10);
|
||||
});
|
||||
});
|
||||
});
|
||||
656
packages/hoppscotch-backend/src/admin/admin.service.ts
Normal file
656
packages/hoppscotch-backend/src/admin/admin.service.ts
Normal file
@@ -0,0 +1,656 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { PubSubService } from '../pubsub/pubsub.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import * as O from 'fp-ts/Option';
|
||||
import { validateEmail } from '../utils';
|
||||
import {
|
||||
ADMIN_CAN_NOT_BE_DELETED,
|
||||
DUPLICATE_EMAIL,
|
||||
EMAIL_FAILED,
|
||||
INVALID_EMAIL,
|
||||
ONLY_ONE_ADMIN_ACCOUNT,
|
||||
TEAM_INVITE_ALREADY_MEMBER,
|
||||
TEAM_INVITE_NO_INVITE_FOUND,
|
||||
USERS_NOT_FOUND,
|
||||
USER_ALREADY_INVITED,
|
||||
USER_INVITATION_DELETION_FAILED,
|
||||
USER_IS_ADMIN,
|
||||
USER_NOT_FOUND,
|
||||
} from '../errors';
|
||||
import { MailerService } from '../mailer/mailer.service';
|
||||
import { InvitedUser } from './invited-user.model';
|
||||
import { TeamService } from '../team/team.service';
|
||||
import { TeamCollectionService } from '../team-collection/team-collection.service';
|
||||
import { TeamRequestService } from '../team-request/team-request.service';
|
||||
import { TeamEnvironmentsService } from '../team-environments/team-environments.service';
|
||||
import { TeamInvitationService } from '../team-invitation/team-invitation.service';
|
||||
import { TeamMemberRole } from '../team/team.model';
|
||||
import { ShortcodeService } from 'src/shortcode/shortcode.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { OffsetPaginationArgs } from 'src/types/input-types.args';
|
||||
import { UserDeletionResult } from 'src/user/user.model';
|
||||
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
constructor(
|
||||
private readonly userService: UserService,
|
||||
private readonly teamService: TeamService,
|
||||
private readonly teamCollectionService: TeamCollectionService,
|
||||
private readonly teamRequestService: TeamRequestService,
|
||||
private readonly teamEnvironmentsService: TeamEnvironmentsService,
|
||||
private readonly teamInvitationService: TeamInvitationService,
|
||||
private readonly pubsub: PubSubService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly mailerService: MailerService,
|
||||
private readonly shortcodeService: ShortcodeService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Fetch all the users in the infra.
|
||||
* @param cursorID Users uid
|
||||
* @param take number of users to fetch
|
||||
* @returns an Either of array of user or error
|
||||
* @deprecated use fetchUsersV2 instead
|
||||
*/
|
||||
async fetchUsers(cursorID: string, take: number) {
|
||||
const allUsers = await this.userService.fetchAllUsers(cursorID, take);
|
||||
return allUsers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all the users in the infra.
|
||||
* @param searchString search on users displayName or email
|
||||
* @param paginationOption pagination options
|
||||
* @returns an Either of array of user or error
|
||||
*/
|
||||
async fetchUsersV2(
|
||||
searchString: string,
|
||||
paginationOption: OffsetPaginationArgs,
|
||||
) {
|
||||
const allUsers = await this.userService.fetchAllUsersV2(
|
||||
searchString,
|
||||
paginationOption,
|
||||
);
|
||||
return allUsers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite a user to join the infra.
|
||||
* @param adminUID Admin's UID
|
||||
* @param adminEmail Admin's email
|
||||
* @param inviteeEmail Invitee's email
|
||||
* @returns an Either of `InvitedUser` object or error
|
||||
*/
|
||||
async inviteUserToSignInViaEmail(
|
||||
adminUID: string,
|
||||
adminEmail: string,
|
||||
inviteeEmail: string,
|
||||
) {
|
||||
if (inviteeEmail.toLowerCase() == adminEmail.toLowerCase()) {
|
||||
return E.left(DUPLICATE_EMAIL);
|
||||
}
|
||||
if (!validateEmail(inviteeEmail)) return E.left(INVALID_EMAIL);
|
||||
|
||||
const alreadyInvitedUser = await this.prisma.invitedUsers.findFirst({
|
||||
where: {
|
||||
inviteeEmail: {
|
||||
equals: inviteeEmail,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
});
|
||||
if (alreadyInvitedUser != null) return E.left(USER_ALREADY_INVITED);
|
||||
|
||||
try {
|
||||
await this.mailerService.sendUserInvitationEmail(inviteeEmail, {
|
||||
template: 'user-invitation',
|
||||
variables: {
|
||||
inviteeEmail: inviteeEmail,
|
||||
magicLink: `${this.configService.get('VITE_BASE_URL')}`,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
return E.left(EMAIL_FAILED);
|
||||
}
|
||||
|
||||
// Add invitee email to the list of invited users by admin
|
||||
const dbInvitedUser = await this.prisma.invitedUsers.create({
|
||||
data: {
|
||||
adminUid: adminUID,
|
||||
adminEmail: adminEmail,
|
||||
inviteeEmail: inviteeEmail,
|
||||
},
|
||||
});
|
||||
|
||||
const invitedUser = <InvitedUser>{
|
||||
adminEmail: dbInvitedUser.adminEmail,
|
||||
adminUid: dbInvitedUser.adminUid,
|
||||
inviteeEmail: dbInvitedUser.inviteeEmail,
|
||||
invitedOn: dbInvitedUser.invitedOn,
|
||||
};
|
||||
|
||||
// Publish invited user subscription
|
||||
await this.pubsub.publish(`admin/${adminUID}/invited`, invitedUser);
|
||||
|
||||
return E.right(invitedUser);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the display name of a user
|
||||
* @param userUid Who's display name is being updated
|
||||
* @param displayName New display name of the user
|
||||
* @returns an Either of boolean or error
|
||||
*/
|
||||
async updateUserDisplayName(userUid: string, displayName: string) {
|
||||
const updatedUser = await this.userService.updateUserDisplayName(
|
||||
userUid,
|
||||
displayName,
|
||||
);
|
||||
if (E.isLeft(updatedUser)) return E.left(updatedUser.left);
|
||||
|
||||
return E.right(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke infra level user invitations
|
||||
* @param inviteeEmails Invitee's emails
|
||||
* @param adminUid Admin Uid
|
||||
* @returns an Either of boolean or error string
|
||||
*/
|
||||
async revokeUserInvitations(inviteeEmails: string[]) {
|
||||
const areAllEmailsValid = inviteeEmails.every((email) =>
|
||||
validateEmail(email),
|
||||
);
|
||||
if (!areAllEmailsValid) {
|
||||
return E.left(INVALID_EMAIL);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.prisma.invitedUsers.deleteMany({
|
||||
where: {
|
||||
inviteeEmail: { in: inviteeEmails, mode: 'insensitive' },
|
||||
},
|
||||
});
|
||||
return E.right(true);
|
||||
} catch (error) {
|
||||
return E.left(USER_INVITATION_DELETION_FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the list of invited users by the admin.
|
||||
* @returns an Either of array of `InvitedUser` object or error
|
||||
*/
|
||||
async fetchInvitedUsers(paginationOption: OffsetPaginationArgs) {
|
||||
const userEmailObjs = await this.prisma.user.findMany({
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
|
||||
const pendingInvitedUsers = await this.prisma.invitedUsers.findMany({
|
||||
take: paginationOption.take,
|
||||
skip: paginationOption.skip,
|
||||
orderBy: {
|
||||
invitedOn: 'desc',
|
||||
},
|
||||
where: {
|
||||
NOT: {
|
||||
inviteeEmail: {
|
||||
in: userEmailObjs.map((user) => user.email),
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const users: InvitedUser[] = pendingInvitedUsers.map(
|
||||
(user) => <InvitedUser>{ ...user },
|
||||
);
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all the teams in the infra.
|
||||
* @param cursorID team id
|
||||
* @param take number of items to fetch
|
||||
* @returns an array of teams
|
||||
*/
|
||||
async fetchAllTeams(cursorID: string, take: number) {
|
||||
const allTeams = await this.teamService.fetchAllTeams(cursorID, take);
|
||||
return allTeams;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the count of all the members in a team.
|
||||
* @param teamID team id
|
||||
* @returns a count of team members
|
||||
*/
|
||||
async membersCountInTeam(teamID: string) {
|
||||
const teamMembersCount = await this.teamService.getCountOfMembersInTeam(
|
||||
teamID,
|
||||
);
|
||||
return teamMembersCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch count of all the collections in a team.
|
||||
* @param teamID team id
|
||||
* @returns a of count of collections
|
||||
*/
|
||||
async collectionCountInTeam(teamID: string) {
|
||||
const teamCollectionsCount =
|
||||
await this.teamCollectionService.totalCollectionsInTeam(teamID);
|
||||
return teamCollectionsCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the count of all the requests in a team.
|
||||
* @param teamID team id
|
||||
* @returns a count of total requests in a team
|
||||
*/
|
||||
async requestCountInTeam(teamID: string) {
|
||||
const teamRequestsCount =
|
||||
await this.teamRequestService.totalRequestsInATeam(teamID);
|
||||
|
||||
return teamRequestsCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the count of all the environments in a team.
|
||||
* @param teamID team id
|
||||
* @returns a count of environments in a team
|
||||
*/
|
||||
async environmentCountInTeam(teamID: string) {
|
||||
const envCount = await this.teamEnvironmentsService.totalEnvsInTeam(teamID);
|
||||
return envCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all the invitations for a given team.
|
||||
* @param teamID team id
|
||||
* @returns an array team invitations
|
||||
*/
|
||||
async pendingInvitationCountInTeam(teamID: string) {
|
||||
const invitations = await this.teamInvitationService.getTeamInvitations(
|
||||
teamID,
|
||||
);
|
||||
|
||||
return invitations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the role of a user in a team
|
||||
* @param userUid users uid
|
||||
* @param teamID team id
|
||||
* @returns an Either of updated `TeamMember` object or error
|
||||
*/
|
||||
async changeRoleOfUserTeam(
|
||||
userUid: string,
|
||||
teamID: string,
|
||||
newRole: TeamMemberRole,
|
||||
) {
|
||||
const updatedTeamMember = await this.teamService.updateTeamMemberRole(
|
||||
teamID,
|
||||
userUid,
|
||||
newRole,
|
||||
);
|
||||
|
||||
if (E.isLeft(updatedTeamMember)) return E.left(updatedTeamMember.left);
|
||||
|
||||
return E.right(updatedTeamMember.right);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the user from a team
|
||||
* @param userUid users uid
|
||||
* @param teamID team id
|
||||
* @returns an Either of boolean or error
|
||||
*/
|
||||
async removeUserFromTeam(userUid: string, teamID: string) {
|
||||
const removedUser = await this.teamService.leaveTeam(teamID, userUid);
|
||||
if (E.isLeft(removedUser)) return E.left(removedUser.left);
|
||||
|
||||
return E.right(removedUser.right);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the user to a team
|
||||
* @param teamID team id
|
||||
* @param userEmail users email
|
||||
* @param role team member role for the user
|
||||
* @returns an Either of boolean or error
|
||||
*/
|
||||
async addUserToTeam(teamID: string, userEmail: string, role: TeamMemberRole) {
|
||||
if (!validateEmail(userEmail)) return E.left(INVALID_EMAIL);
|
||||
|
||||
const user = await this.userService.findUserByEmail(userEmail);
|
||||
if (O.isNone(user)) return E.left(USER_NOT_FOUND);
|
||||
|
||||
const teamMember = await this.teamService.getTeamMemberTE(
|
||||
teamID,
|
||||
user.value.uid,
|
||||
)();
|
||||
if (E.isLeft(teamMember)) {
|
||||
const addedUser = await this.teamService.addMemberToTeamWithEmail(
|
||||
teamID,
|
||||
userEmail,
|
||||
role,
|
||||
);
|
||||
if (E.isLeft(addedUser)) return E.left(addedUser.left);
|
||||
|
||||
const userInvitation =
|
||||
await this.teamInvitationService.getTeamInviteByEmailAndTeamID(
|
||||
userEmail,
|
||||
teamID,
|
||||
);
|
||||
|
||||
if (E.isRight(userInvitation)) {
|
||||
await this.teamInvitationService.revokeInvitation(
|
||||
userInvitation.right.id,
|
||||
);
|
||||
}
|
||||
|
||||
return E.right(addedUser.right);
|
||||
}
|
||||
|
||||
return E.left(TEAM_INVITE_ALREADY_MEMBER);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new team
|
||||
* @param userUid user uid
|
||||
* @param name team name
|
||||
* @returns an Either of `Team` object or error
|
||||
*/
|
||||
async createATeam(userUid: string, name: string) {
|
||||
const validUser = await this.userService.findUserById(userUid);
|
||||
if (O.isNone(validUser)) return E.left(USER_NOT_FOUND);
|
||||
|
||||
const createdTeam = await this.teamService.createTeam(name, userUid);
|
||||
if (E.isLeft(createdTeam)) return E.left(createdTeam.left);
|
||||
|
||||
return E.right(createdTeam.right);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames a team
|
||||
* @param teamID team ID
|
||||
* @param newName new team name
|
||||
* @returns an Either of `Team` object or error
|
||||
*/
|
||||
async renameATeam(teamID: string, newName: string) {
|
||||
const renamedTeam = await this.teamService.renameTeam(teamID, newName);
|
||||
if (E.isLeft(renamedTeam)) return E.left(renamedTeam.left);
|
||||
|
||||
return E.right(renamedTeam.right);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a team
|
||||
* @param teamID team ID
|
||||
* @returns an Either of boolean or error
|
||||
*/
|
||||
async deleteATeam(teamID: string) {
|
||||
const deleteTeam = await this.teamService.deleteTeam(teamID);
|
||||
if (E.isLeft(deleteTeam)) return E.left(deleteTeam.left);
|
||||
|
||||
return E.right(deleteTeam.right);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all admin accounts
|
||||
* @returns an array of admin users
|
||||
*/
|
||||
async fetchAdmins() {
|
||||
const admins = this.userService.fetchAdminUsers();
|
||||
return admins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a user by UID
|
||||
* @param userUid User UID
|
||||
* @returns an Either of `User` obj or error
|
||||
*/
|
||||
async fetchUserInfo(userUid: string) {
|
||||
const user = await this.userService.findUserById(userUid);
|
||||
if (O.isNone(user)) return E.left(USER_NOT_FOUND);
|
||||
|
||||
return E.right(user.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a user account by UID
|
||||
* @param userUid User UID
|
||||
* @returns an Either of boolean or error
|
||||
* @deprecated use removeUserAccounts instead
|
||||
*/
|
||||
async removeUserAccount(userUid: string) {
|
||||
const user = await this.userService.findUserById(userUid);
|
||||
if (O.isNone(user)) return E.left(USER_NOT_FOUND);
|
||||
|
||||
if (user.value.isAdmin) return E.left(USER_IS_ADMIN);
|
||||
|
||||
const delUser = await this.userService.deleteUserByUID(user.value)();
|
||||
if (E.isLeft(delUser)) return E.left(delUser.left);
|
||||
return E.right(delUser.right);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove user (not Admin) accounts by UIDs
|
||||
* @param userUIDs User UIDs
|
||||
* @returns an Either of boolean or error
|
||||
*/
|
||||
async removeUserAccounts(userUIDs: string[]) {
|
||||
const userDeleteResult: UserDeletionResult[] = [];
|
||||
|
||||
// step 1: fetch all users
|
||||
const allUsersList = await this.userService.findUsersByIds(userUIDs);
|
||||
if (allUsersList.length === 0) return E.left(USERS_NOT_FOUND);
|
||||
|
||||
// step 2: admin user can not be deleted without removing admin status/role
|
||||
allUsersList.forEach((user) => {
|
||||
if (user.isAdmin) {
|
||||
userDeleteResult.push({
|
||||
userUID: user.uid,
|
||||
isDeleted: false,
|
||||
errorMessage: ADMIN_CAN_NOT_BE_DELETED,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const nonAdminUsers = allUsersList.filter((user) => !user.isAdmin);
|
||||
let deletedUserEmails: string[] = [];
|
||||
|
||||
// step 3: delete non-admin users
|
||||
const deletionPromises = nonAdminUsers.map((user) => {
|
||||
return this.userService
|
||||
.deleteUserByUID(user)()
|
||||
.then((res) => {
|
||||
if (E.isLeft(res)) {
|
||||
return {
|
||||
userUID: user.uid,
|
||||
isDeleted: false,
|
||||
errorMessage: res.left,
|
||||
} as UserDeletionResult;
|
||||
}
|
||||
|
||||
deletedUserEmails.push(user.email);
|
||||
return {
|
||||
userUID: user.uid,
|
||||
isDeleted: true,
|
||||
errorMessage: null,
|
||||
} as UserDeletionResult;
|
||||
});
|
||||
});
|
||||
const promiseResult = await Promise.allSettled(deletionPromises);
|
||||
|
||||
// step 4: revoke all the invites sent to the deleted users
|
||||
await this.revokeUserInvitations(deletedUserEmails);
|
||||
|
||||
// step 5: return the result
|
||||
promiseResult.forEach((result) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
userDeleteResult.push(result.value);
|
||||
}
|
||||
});
|
||||
|
||||
return E.right(userDeleteResult);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a user an admin
|
||||
* @param userUid User UID
|
||||
* @returns an Either of boolean or error
|
||||
* @deprecated use makeUsersAdmin instead
|
||||
*/
|
||||
async makeUserAdmin(userUID: string) {
|
||||
const admin = await this.userService.makeAdmin(userUID);
|
||||
if (E.isLeft(admin)) return E.left(admin.left);
|
||||
return E.right(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make users to admin
|
||||
* @param userUid User UIDs
|
||||
* @returns an Either of boolean or error
|
||||
*/
|
||||
async makeUsersAdmin(userUIDs: string[]) {
|
||||
const isUpdated = await this.userService.makeAdmins(userUIDs);
|
||||
if (E.isLeft(isUpdated)) return E.left(isUpdated.left);
|
||||
return E.right(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove user as admin
|
||||
* @param userUid User UID
|
||||
* @returns an Either of boolean or error
|
||||
* @deprecated use demoteUsersByAdmin instead
|
||||
*/
|
||||
async removeUserAsAdmin(userUID: string) {
|
||||
const adminUsers = await this.userService.fetchAdminUsers();
|
||||
if (adminUsers.length === 1) return E.left(ONLY_ONE_ADMIN_ACCOUNT);
|
||||
|
||||
const admin = await this.userService.removeUserAsAdmin(userUID);
|
||||
if (E.isLeft(admin)) return E.left(admin.left);
|
||||
return E.right(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove users as admin
|
||||
* @param userUIDs User UIDs
|
||||
* @returns an Either of boolean or error
|
||||
*/
|
||||
async demoteUsersByAdmin(userUIDs: string[]) {
|
||||
const adminUsers = await this.userService.fetchAdminUsers();
|
||||
|
||||
const remainingAdmins = adminUsers.filter(
|
||||
(adminUser) => !userUIDs.includes(adminUser.uid),
|
||||
);
|
||||
if (remainingAdmins.length < 1) {
|
||||
return E.left(ONLY_ONE_ADMIN_ACCOUNT);
|
||||
}
|
||||
|
||||
const isUpdated = await this.userService.removeUsersAsAdmin(userUIDs);
|
||||
if (E.isLeft(isUpdated)) return E.left(isUpdated.left);
|
||||
return E.right(isUpdated.right);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch list of all the Users in org
|
||||
* @returns number of users in the org
|
||||
*/
|
||||
async getUsersCount() {
|
||||
const usersCount = this.userService.getUsersCount();
|
||||
return usersCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch list of all the Teams in org
|
||||
* @returns number of users in the org
|
||||
*/
|
||||
async getTeamsCount() {
|
||||
const teamsCount = this.teamService.getTeamsCount();
|
||||
return teamsCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch list of all the Team Collections in org
|
||||
* @returns number of users in the org
|
||||
*/
|
||||
async getTeamCollectionsCount() {
|
||||
const teamCollectionCount =
|
||||
this.teamCollectionService.getTeamCollectionsCount();
|
||||
return teamCollectionCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch list of all the Team Requests in org
|
||||
* @returns number of users in the org
|
||||
*/
|
||||
async getTeamRequestsCount() {
|
||||
const teamRequestCount = this.teamRequestService.getTeamRequestsCount();
|
||||
return teamRequestCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get team info by ID
|
||||
* @param teamID Team ID
|
||||
* @returns an Either of `Team` or error
|
||||
*/
|
||||
async getTeamInfo(teamID: string) {
|
||||
const team = await this.teamService.getTeamWithIDTE(teamID)();
|
||||
if (E.isLeft(team)) return E.left(team.left);
|
||||
return E.right(team.right);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a team invite by ID
|
||||
* @param inviteID Team Invite ID
|
||||
* @returns an Either of boolean or error
|
||||
*/
|
||||
async revokeTeamInviteByID(inviteID: string) {
|
||||
const teamInvite = await this.teamInvitationService.revokeInvitation(
|
||||
inviteID,
|
||||
);
|
||||
|
||||
if (E.isLeft(teamInvite)) return E.left(teamInvite.left);
|
||||
|
||||
return E.right(teamInvite.right);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all created ShortCodes
|
||||
*
|
||||
* @param args Pagination arguments
|
||||
* @param userEmail User email
|
||||
* @returns ShortcodeWithUserEmail
|
||||
*/
|
||||
async fetchAllShortcodes(
|
||||
cursorID: string,
|
||||
take: number,
|
||||
userEmail: string = null,
|
||||
) {
|
||||
return this.shortcodeService.fetchAllShortcodes(
|
||||
{ cursor: cursorID, take },
|
||||
userEmail,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a Shortcode
|
||||
*
|
||||
* @param shortcodeID ID of Shortcode being deleted
|
||||
* @returns Boolean on successful deletion
|
||||
*/
|
||||
async deleteShortcode(shortcodeID: string) {
|
||||
const result = await this.shortcodeService.deleteShortcode(shortcodeID);
|
||||
|
||||
if (E.isLeft(result)) return E.left(result.left);
|
||||
return E.right(result.right);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { GqlExecutionContext } from '@nestjs/graphql';
|
||||
|
||||
export const GqlAdmin = createParamDecorator(
|
||||
(data: unknown, context: ExecutionContext) => {
|
||||
const ctx = GqlExecutionContext.create(context);
|
||||
return ctx.getContext().req.user;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Injectable, ExecutionContext, CanActivate } from '@nestjs/common';
|
||||
import { GqlExecutionContext } from '@nestjs/graphql';
|
||||
|
||||
@Injectable()
|
||||
export class GqlAdminGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const ctx = GqlExecutionContext.create(context);
|
||||
const { req, headers } = ctx.getContext();
|
||||
const request = headers ? headers : req;
|
||||
const user = request.user;
|
||||
if (user.isAdmin) return true;
|
||||
else return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Injectable, ExecutionContext, CanActivate } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class RESTAdminGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
|
||||
return user.isAdmin;
|
||||
}
|
||||
}
|
||||
10
packages/hoppscotch-backend/src/admin/infra.model.ts
Normal file
10
packages/hoppscotch-backend/src/admin/infra.model.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
import { Admin } from './admin.model';
|
||||
|
||||
@ObjectType()
|
||||
export class Infra {
|
||||
@Field(() => Admin, {
|
||||
description: 'Admin who executed the action',
|
||||
})
|
||||
executedBy: Admin;
|
||||
}
|
||||
381
packages/hoppscotch-backend/src/admin/infra.resolver.ts
Normal file
381
packages/hoppscotch-backend/src/admin/infra.resolver.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
ID,
|
||||
Mutation,
|
||||
Query,
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
|
||||
import { Infra } from './infra.model';
|
||||
import { AdminService } from './admin.service';
|
||||
import { GqlAuthGuard } from 'src/guards/gql-auth.guard';
|
||||
import { GqlAdminGuard } from './guards/gql-admin.guard';
|
||||
import { User } from 'src/user/user.model';
|
||||
import { AuthUser } from 'src/types/AuthUser';
|
||||
import { throwErr } from 'src/utils';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import { Admin } from './admin.model';
|
||||
import {
|
||||
OffsetPaginationArgs,
|
||||
PaginationArgs,
|
||||
} from 'src/types/input-types.args';
|
||||
import { InvitedUser } from './invited-user.model';
|
||||
import { Team } from 'src/team/team.model';
|
||||
import { TeamInvitation } from 'src/team-invitation/team-invitation.model';
|
||||
import { GqlAdmin } from './decorators/gql-admin.decorator';
|
||||
import { ShortcodeWithUserEmail } from 'src/shortcode/shortcode.model';
|
||||
import { InfraConfig } from 'src/infra-config/infra-config.model';
|
||||
import { InfraConfigService } from 'src/infra-config/infra-config.service';
|
||||
import {
|
||||
EnableAndDisableSSOArgs,
|
||||
InfraConfigArgs,
|
||||
} from 'src/infra-config/input-args';
|
||||
import { InfraConfigEnum } from 'src/types/InfraConfig';
|
||||
import { ServiceStatus } from 'src/infra-config/helper';
|
||||
|
||||
@UseGuards(GqlThrottlerGuard)
|
||||
@Resolver(() => Infra)
|
||||
export class InfraResolver {
|
||||
constructor(
|
||||
private adminService: AdminService,
|
||||
private infraConfigService: InfraConfigService,
|
||||
) {}
|
||||
|
||||
@Query(() => Infra, {
|
||||
description: 'Fetch details of the Infrastructure',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
infra(@GqlAdmin() admin: Admin) {
|
||||
const infra: Infra = { executedBy: admin };
|
||||
return infra;
|
||||
}
|
||||
|
||||
@ResolveField(() => [User], {
|
||||
description: 'Returns a list of all admin users in infra',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async admins() {
|
||||
const admins = await this.adminService.fetchAdmins();
|
||||
return admins;
|
||||
}
|
||||
|
||||
@ResolveField(() => User, {
|
||||
description: 'Returns a user info by UID',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async userInfo(
|
||||
@Args({
|
||||
name: 'userUid',
|
||||
type: () => ID,
|
||||
description: 'The user UID',
|
||||
})
|
||||
userUid: string,
|
||||
): Promise<AuthUser> {
|
||||
const user = await this.adminService.fetchUserInfo(userUid);
|
||||
if (E.isLeft(user)) throwErr(user.left);
|
||||
return user.right;
|
||||
}
|
||||
|
||||
@ResolveField(() => [User], {
|
||||
description: 'Returns a list of all the users in infra',
|
||||
deprecationReason: 'Use allUsersV2 instead',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async allUsers(@Args() args: PaginationArgs): Promise<AuthUser[]> {
|
||||
const users = await this.adminService.fetchUsers(args.cursor, args.take);
|
||||
return users;
|
||||
}
|
||||
|
||||
@ResolveField(() => [User], {
|
||||
description: 'Returns a list of all the users in infra',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async allUsersV2(
|
||||
@Args({
|
||||
name: 'searchString',
|
||||
nullable: true,
|
||||
description: 'Search on users displayName or email',
|
||||
})
|
||||
searchString: string,
|
||||
@Args() paginationOption: OffsetPaginationArgs,
|
||||
): Promise<AuthUser[]> {
|
||||
const users = await this.adminService.fetchUsersV2(
|
||||
searchString,
|
||||
paginationOption,
|
||||
);
|
||||
return users;
|
||||
}
|
||||
|
||||
@ResolveField(() => [InvitedUser], {
|
||||
description: 'Returns a list of all the invited users',
|
||||
})
|
||||
async invitedUsers(
|
||||
@Args() args: OffsetPaginationArgs,
|
||||
): Promise<InvitedUser[]> {
|
||||
const users = await this.adminService.fetchInvitedUsers(args);
|
||||
return users;
|
||||
}
|
||||
|
||||
@ResolveField(() => [Team], {
|
||||
description: 'Returns a list of all the teams in the infra',
|
||||
})
|
||||
async allTeams(@Args() args: PaginationArgs): Promise<Team[]> {
|
||||
const teams = await this.adminService.fetchAllTeams(args.cursor, args.take);
|
||||
return teams;
|
||||
}
|
||||
|
||||
@ResolveField(() => Team, {
|
||||
description: 'Returns a team info by ID when requested by Admin',
|
||||
})
|
||||
async teamInfo(
|
||||
@Args({
|
||||
name: 'teamID',
|
||||
type: () => ID,
|
||||
description: 'Team ID for which info to fetch',
|
||||
})
|
||||
teamID: string,
|
||||
): Promise<Team> {
|
||||
const team = await this.adminService.getTeamInfo(teamID);
|
||||
if (E.isLeft(team)) throwErr(team.left);
|
||||
return team.right;
|
||||
}
|
||||
|
||||
@ResolveField(() => Number, {
|
||||
description: 'Return count of all the members in a team',
|
||||
})
|
||||
async membersCountInTeam(
|
||||
@Args({
|
||||
name: 'teamID',
|
||||
type: () => ID,
|
||||
description: 'Team ID for which team members to fetch',
|
||||
nullable: false,
|
||||
})
|
||||
teamID: string,
|
||||
): Promise<number> {
|
||||
const teamMembersCount = await this.adminService.membersCountInTeam(teamID);
|
||||
return teamMembersCount;
|
||||
}
|
||||
|
||||
@ResolveField(() => Number, {
|
||||
description: 'Return count of all the stored collections in a team',
|
||||
})
|
||||
async collectionCountInTeam(
|
||||
@Args({
|
||||
name: 'teamID',
|
||||
type: () => ID,
|
||||
description: 'Team ID for which team members to fetch',
|
||||
})
|
||||
teamID: string,
|
||||
): Promise<number> {
|
||||
const teamCollCount = await this.adminService.collectionCountInTeam(teamID);
|
||||
return teamCollCount;
|
||||
}
|
||||
|
||||
@ResolveField(() => Number, {
|
||||
description: 'Return count of all the stored requests in a team',
|
||||
})
|
||||
async requestCountInTeam(
|
||||
@Args({
|
||||
name: 'teamID',
|
||||
type: () => ID,
|
||||
description: 'Team ID for which team members to fetch',
|
||||
})
|
||||
teamID: string,
|
||||
): Promise<number> {
|
||||
const teamReqCount = await this.adminService.requestCountInTeam(teamID);
|
||||
return teamReqCount;
|
||||
}
|
||||
|
||||
@ResolveField(() => Number, {
|
||||
description: 'Return count of all the stored environments in a team',
|
||||
})
|
||||
async environmentCountInTeam(
|
||||
@Args({
|
||||
name: 'teamID',
|
||||
type: () => ID,
|
||||
description: 'Team ID for which team members to fetch',
|
||||
})
|
||||
teamID: string,
|
||||
): Promise<number> {
|
||||
const envsCount = await this.adminService.environmentCountInTeam(teamID);
|
||||
return envsCount;
|
||||
}
|
||||
|
||||
@ResolveField(() => [TeamInvitation], {
|
||||
description: 'Return all the pending invitations in a team',
|
||||
})
|
||||
async pendingInvitationCountInTeam(
|
||||
@Args({
|
||||
name: 'teamID',
|
||||
type: () => ID,
|
||||
description: 'Team ID for which team members to fetch',
|
||||
})
|
||||
teamID: string,
|
||||
) {
|
||||
const invitations = await this.adminService.pendingInvitationCountInTeam(
|
||||
teamID,
|
||||
);
|
||||
return invitations;
|
||||
}
|
||||
|
||||
@ResolveField(() => Number, {
|
||||
description: 'Return total number of Users in organization',
|
||||
})
|
||||
async usersCount() {
|
||||
return this.adminService.getUsersCount();
|
||||
}
|
||||
|
||||
@ResolveField(() => Number, {
|
||||
description: 'Return total number of Teams in organization',
|
||||
})
|
||||
async teamsCount() {
|
||||
return this.adminService.getTeamsCount();
|
||||
}
|
||||
|
||||
@ResolveField(() => Number, {
|
||||
description: 'Return total number of Team Collections in organization',
|
||||
})
|
||||
async teamCollectionsCount() {
|
||||
return this.adminService.getTeamCollectionsCount();
|
||||
}
|
||||
|
||||
@ResolveField(() => Number, {
|
||||
description: 'Return total number of Team Requests in organization',
|
||||
})
|
||||
async teamRequestsCount() {
|
||||
return this.adminService.getTeamRequestsCount();
|
||||
}
|
||||
|
||||
@ResolveField(() => [ShortcodeWithUserEmail], {
|
||||
description: 'Returns a list of all the shortcodes in the infra',
|
||||
})
|
||||
async allShortcodes(
|
||||
@Args() args: PaginationArgs,
|
||||
@Args({
|
||||
name: 'userEmail',
|
||||
nullable: true,
|
||||
description: 'Users email to filter shortcodes by',
|
||||
})
|
||||
userEmail: string,
|
||||
) {
|
||||
return await this.adminService.fetchAllShortcodes(
|
||||
args.cursor,
|
||||
args.take,
|
||||
userEmail,
|
||||
);
|
||||
}
|
||||
|
||||
@Query(() => [InfraConfig], {
|
||||
description: 'Retrieve configuration details for the instance',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async infraConfigs(
|
||||
@Args({
|
||||
name: 'configNames',
|
||||
type: () => [InfraConfigEnum],
|
||||
description: 'Configs to fetch',
|
||||
})
|
||||
names: InfraConfigEnum[],
|
||||
) {
|
||||
const infraConfigs = await this.infraConfigService.getMany(names);
|
||||
if (E.isLeft(infraConfigs)) throwErr(infraConfigs.left);
|
||||
return infraConfigs.right;
|
||||
}
|
||||
|
||||
@Query(() => [String], {
|
||||
description: 'Allowed Auth Provider list',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
allowedAuthProviders() {
|
||||
return this.infraConfigService.getAllowedAuthProviders();
|
||||
}
|
||||
|
||||
/* Mutations */
|
||||
|
||||
@Mutation(() => [InfraConfig], {
|
||||
description: 'Update Infra Configs',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async updateInfraConfigs(
|
||||
@Args({
|
||||
name: 'infraConfigs',
|
||||
type: () => [InfraConfigArgs],
|
||||
description: 'InfraConfigs to update',
|
||||
})
|
||||
infraConfigs: InfraConfigArgs[],
|
||||
) {
|
||||
const updatedRes = await this.infraConfigService.updateMany(infraConfigs);
|
||||
if (E.isLeft(updatedRes)) throwErr(updatedRes.left);
|
||||
return updatedRes.right;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Enable or disable analytics collection',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async toggleAnalyticsCollection(
|
||||
@Args({
|
||||
name: 'status',
|
||||
type: () => ServiceStatus,
|
||||
description: 'Toggle analytics collection',
|
||||
})
|
||||
analyticsCollectionStatus: ServiceStatus,
|
||||
) {
|
||||
const res = await this.infraConfigService.toggleAnalyticsCollection(
|
||||
analyticsCollectionStatus,
|
||||
);
|
||||
if (E.isLeft(res)) throwErr(res.left);
|
||||
return res.right;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Reset Infra Configs with default values (.env)',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async resetInfraConfigs() {
|
||||
const resetRes = await this.infraConfigService.reset();
|
||||
if (E.isLeft(resetRes)) throwErr(resetRes.left);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Enable or Disable SSO for login/signup',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async enableAndDisableSSO(
|
||||
@Args({
|
||||
name: 'providerInfo',
|
||||
type: () => [EnableAndDisableSSOArgs],
|
||||
description: 'SSO provider and status',
|
||||
})
|
||||
providerInfo: EnableAndDisableSSOArgs[],
|
||||
) {
|
||||
const isUpdated = await this.infraConfigService.enableAndDisableSSO(
|
||||
providerInfo,
|
||||
);
|
||||
if (E.isLeft(isUpdated)) throwErr(isUpdated.left);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Enable or Disable SMTP for sending emails',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async toggleSMTP(
|
||||
@Args({
|
||||
name: 'status',
|
||||
type: () => ServiceStatus,
|
||||
description: 'Toggle SMTP',
|
||||
})
|
||||
status: ServiceStatus,
|
||||
) {
|
||||
const isUpdated = await this.infraConfigService.enableAndDisableSMTP(
|
||||
status,
|
||||
);
|
||||
if (E.isLeft(isUpdated)) throwErr(isUpdated.left);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
43
packages/hoppscotch-backend/src/admin/input-types.args.ts
Normal file
43
packages/hoppscotch-backend/src/admin/input-types.args.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Field, ID, ArgsType } from '@nestjs/graphql';
|
||||
import { TeamMemberRole } from '../team/team.model';
|
||||
|
||||
@ArgsType()
|
||||
export class ChangeUserRoleInTeamArgs {
|
||||
@Field(() => ID, {
|
||||
name: 'userUID',
|
||||
description: 'users UID',
|
||||
})
|
||||
userUID: string;
|
||||
@Field(() => ID, {
|
||||
name: 'teamID',
|
||||
description: 'team ID',
|
||||
})
|
||||
teamID: string;
|
||||
|
||||
@Field(() => TeamMemberRole, {
|
||||
name: 'newRole',
|
||||
description: 'updated team role',
|
||||
})
|
||||
newRole: TeamMemberRole;
|
||||
}
|
||||
|
||||
@ArgsType()
|
||||
export class AddUserToTeamArgs {
|
||||
@Field(() => ID, {
|
||||
name: 'teamID',
|
||||
description: 'team ID',
|
||||
})
|
||||
teamID: string;
|
||||
|
||||
@Field(() => TeamMemberRole, {
|
||||
name: 'role',
|
||||
description: 'The role of the user to add in the team',
|
||||
})
|
||||
role: TeamMemberRole;
|
||||
|
||||
@Field({
|
||||
name: 'userEmail',
|
||||
description: 'Email of the user to add to team',
|
||||
})
|
||||
userEmail: string;
|
||||
}
|
||||
24
packages/hoppscotch-backend/src/admin/invited-user.model.ts
Normal file
24
packages/hoppscotch-backend/src/admin/invited-user.model.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { ObjectType, ID, Field } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType()
|
||||
export class InvitedUser {
|
||||
@Field(() => ID, {
|
||||
description: 'Admin UID',
|
||||
})
|
||||
adminUid: string;
|
||||
|
||||
@Field({
|
||||
description: 'Admin email',
|
||||
})
|
||||
adminEmail: string;
|
||||
|
||||
@Field({
|
||||
description: 'Invitee email',
|
||||
})
|
||||
inviteeEmail: string;
|
||||
|
||||
@Field({
|
||||
description: 'Date when the user invitation was sent',
|
||||
})
|
||||
invitedOn: Date;
|
||||
}
|
||||
9
packages/hoppscotch-backend/src/app.controller.ts
Normal file
9
packages/hoppscotch-backend/src/app.controller.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
@Controller('ping')
|
||||
export class AppController {
|
||||
@Get()
|
||||
ping(): string {
|
||||
return 'Success';
|
||||
}
|
||||
}
|
||||
117
packages/hoppscotch-backend/src/app.module.ts
Normal file
117
packages/hoppscotch-backend/src/app.module.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { ForbiddenException, HttpException, Module } from '@nestjs/common';
|
||||
import { GraphQLModule } from '@nestjs/graphql';
|
||||
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
|
||||
import { UserModule } from './user/user.module';
|
||||
import { GQLComplexityPlugin } from './plugins/GQLComplexityPlugin';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { UserSettingsModule } from './user-settings/user-settings.module';
|
||||
import { UserEnvironmentsModule } from './user-environment/user-environments.module';
|
||||
import { UserRequestModule } from './user-request/user-request.module';
|
||||
import { UserHistoryModule } from './user-history/user-history.module';
|
||||
import { subscriptionContextCookieParser } from './auth/helper';
|
||||
import { TeamModule } from './team/team.module';
|
||||
import { TeamEnvironmentsModule } from './team-environments/team-environments.module';
|
||||
import { TeamCollectionModule } from './team-collection/team-collection.module';
|
||||
import { TeamRequestModule } from './team-request/team-request.module';
|
||||
import { TeamInvitationModule } from './team-invitation/team-invitation.module';
|
||||
import { AdminModule } from './admin/admin.module';
|
||||
import { UserCollectionModule } from './user-collection/user-collection.module';
|
||||
import { ShortcodeModule } from './shortcode/shortcode.module';
|
||||
import { COOKIES_NOT_FOUND } from './errors';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
import { AppController } from './app.controller';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { InfraConfigModule } from './infra-config/infra-config.module';
|
||||
import { loadInfraConfiguration } from './infra-config/helper';
|
||||
import { MailerModule } from './mailer/mailer.module';
|
||||
import { PosthogModule } from './posthog/posthog.module';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { AccessTokenModule } from './access-token/access-token.module';
|
||||
import { UserLastActiveOnInterceptor } from './interceptors/user-last-active-on.interceptor';
|
||||
import { InfraTokenModule } from './infra-token/infra-token.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [async () => loadInfraConfiguration()],
|
||||
}),
|
||||
GraphQLModule.forRootAsync<ApolloDriverConfig>({
|
||||
driver: ApolloDriver,
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: async (configService: ConfigService) => {
|
||||
return {
|
||||
buildSchemaOptions: {
|
||||
numberScalarMode: 'integer',
|
||||
},
|
||||
playground: configService.get('PRODUCTION') !== 'true',
|
||||
autoSchemaFile: true,
|
||||
installSubscriptionHandlers: true,
|
||||
subscriptions: {
|
||||
'subscriptions-transport-ws': {
|
||||
path: '/graphql',
|
||||
onConnect: (_, websocket) => {
|
||||
try {
|
||||
const cookies = subscriptionContextCookieParser(
|
||||
websocket.upgradeReq.headers.cookie,
|
||||
);
|
||||
return {
|
||||
headers: { ...websocket?.upgradeReq?.headers, cookies },
|
||||
};
|
||||
} catch (error) {
|
||||
throw new HttpException(COOKIES_NOT_FOUND, 400, {
|
||||
cause: new Error(COOKIES_NOT_FOUND),
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
context: ({ req, res, connection }) => ({
|
||||
req,
|
||||
res,
|
||||
connection,
|
||||
}),
|
||||
};
|
||||
},
|
||||
}),
|
||||
ThrottlerModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: async (configService: ConfigService) => [
|
||||
{
|
||||
ttl: +configService.get('RATE_LIMIT_TTL'),
|
||||
limit: +configService.get('RATE_LIMIT_MAX'),
|
||||
},
|
||||
],
|
||||
}),
|
||||
MailerModule.register(),
|
||||
UserModule,
|
||||
AuthModule.register(),
|
||||
AdminModule,
|
||||
UserSettingsModule,
|
||||
UserEnvironmentsModule,
|
||||
UserHistoryModule,
|
||||
UserRequestModule,
|
||||
TeamModule,
|
||||
TeamEnvironmentsModule,
|
||||
TeamCollectionModule,
|
||||
TeamRequestModule,
|
||||
TeamInvitationModule,
|
||||
UserCollectionModule,
|
||||
ShortcodeModule,
|
||||
InfraConfigModule,
|
||||
PosthogModule,
|
||||
ScheduleModule.forRoot(),
|
||||
HealthModule,
|
||||
AccessTokenModule,
|
||||
InfraTokenModule,
|
||||
],
|
||||
providers: [
|
||||
GQLComplexityPlugin,
|
||||
{ provide: 'APP_INTERCEPTOR', useClass: UserLastActiveOnInterceptor },
|
||||
],
|
||||
controllers: [AppController],
|
||||
})
|
||||
export class AppModule {}
|
||||
196
packages/hoppscotch-backend/src/auth/auth.controller.ts
Normal file
196
packages/hoppscotch-backend/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Query,
|
||||
Request,
|
||||
Res,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { SignInMagicDto } from './dto/signin-magic.dto';
|
||||
import { VerifyMagicDto } from './dto/verify-magic.dto';
|
||||
import { Response } from 'express';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import { RTJwtAuthGuard } from './guards/rt-jwt-auth.guard';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { GqlUser } from 'src/decorators/gql-user.decorator';
|
||||
import { AuthUser } from 'src/types/AuthUser';
|
||||
import { RTCookie } from 'src/decorators/rt-cookie.decorator';
|
||||
import { AuthProvider, authCookieHandler, authProviderCheck } from './helper';
|
||||
import { GoogleSSOGuard } from './guards/google-sso.guard';
|
||||
import { GithubSSOGuard } from './guards/github-sso.guard';
|
||||
import { MicrosoftSSOGuard } from './guards/microsoft-sso-.guard';
|
||||
import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard';
|
||||
import { SkipThrottle } from '@nestjs/throttler';
|
||||
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { throwHTTPErr } from 'src/utils';
|
||||
import { UserLastLoginInterceptor } from 'src/interceptors/user-last-login.interceptor';
|
||||
|
||||
@UseGuards(ThrottlerBehindProxyGuard)
|
||||
@Controller({ path: 'auth', version: '1' })
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
@Get('providers')
|
||||
async getAuthProviders() {
|
||||
const providers = await this.authService.getAuthProviders();
|
||||
return { providers };
|
||||
}
|
||||
|
||||
/**
|
||||
** Route to initiate magic-link auth for a users email
|
||||
*/
|
||||
@Post('signin')
|
||||
async signInMagicLink(
|
||||
@Body() authData: SignInMagicDto,
|
||||
@Query('origin') origin: string,
|
||||
) {
|
||||
if (
|
||||
!authProviderCheck(
|
||||
AuthProvider.EMAIL,
|
||||
this.configService.get('INFRA.VITE_ALLOWED_AUTH_PROVIDERS'),
|
||||
)
|
||||
) {
|
||||
throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 });
|
||||
}
|
||||
|
||||
const deviceIdToken = await this.authService.signInMagicLink(
|
||||
authData.email,
|
||||
origin,
|
||||
);
|
||||
if (E.isLeft(deviceIdToken)) throwHTTPErr(deviceIdToken.left);
|
||||
return deviceIdToken.right;
|
||||
}
|
||||
|
||||
/**
|
||||
** Route to verify and sign in a valid user via magic-link
|
||||
*/
|
||||
@Post('verify')
|
||||
async verify(@Body() data: VerifyMagicDto, @Res() res: Response) {
|
||||
const authTokens = await this.authService.verifyMagicLinkTokens(data);
|
||||
if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);
|
||||
authCookieHandler(res, authTokens.right, false, null);
|
||||
}
|
||||
|
||||
/**
|
||||
** Route to refresh auth tokens with Refresh Token Rotation
|
||||
* @see https://auth0.com/docs/secure/tokens/refresh-tokens/refresh-token-rotation
|
||||
*/
|
||||
@Get('refresh')
|
||||
@UseGuards(RTJwtAuthGuard)
|
||||
async refresh(
|
||||
@GqlUser() user: AuthUser,
|
||||
@RTCookie() refresh_token: string,
|
||||
@Res() res,
|
||||
) {
|
||||
const newTokenPair = await this.authService.refreshAuthTokens(
|
||||
refresh_token,
|
||||
user,
|
||||
);
|
||||
if (E.isLeft(newTokenPair)) throwHTTPErr(newTokenPair.left);
|
||||
authCookieHandler(res, newTokenPair.right, false, null);
|
||||
}
|
||||
|
||||
/**
|
||||
** Route to initiate SSO auth via Google
|
||||
*/
|
||||
@Get('google')
|
||||
@UseGuards(GoogleSSOGuard)
|
||||
async googleAuth(@Request() req) {}
|
||||
|
||||
/**
|
||||
** Callback URL for Google SSO
|
||||
* @see https://auth0.com/docs/get-started/authentication-and-authorization-flow/authorization-code-flow#how-it-works
|
||||
*/
|
||||
@Get('google/callback')
|
||||
@SkipThrottle()
|
||||
@UseGuards(GoogleSSOGuard)
|
||||
@UseInterceptors(UserLastLoginInterceptor)
|
||||
async googleAuthRedirect(@Request() req, @Res() res) {
|
||||
const authTokens = await this.authService.generateAuthTokens(req.user.uid);
|
||||
if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);
|
||||
authCookieHandler(
|
||||
res,
|
||||
authTokens.right,
|
||||
true,
|
||||
req.authInfo.state.redirect_uri,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
** Route to initiate SSO auth via Github
|
||||
*/
|
||||
@Get('github')
|
||||
@UseGuards(GithubSSOGuard)
|
||||
async githubAuth(@Request() req) {}
|
||||
|
||||
/**
|
||||
** Callback URL for Github SSO
|
||||
* @see https://auth0.com/docs/get-started/authentication-and-authorization-flow/authorization-code-flow#how-it-works
|
||||
*/
|
||||
@Get('github/callback')
|
||||
@SkipThrottle()
|
||||
@UseGuards(GithubSSOGuard)
|
||||
@UseInterceptors(UserLastLoginInterceptor)
|
||||
async githubAuthRedirect(@Request() req, @Res() res) {
|
||||
const authTokens = await this.authService.generateAuthTokens(req.user.uid);
|
||||
if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);
|
||||
authCookieHandler(
|
||||
res,
|
||||
authTokens.right,
|
||||
true,
|
||||
req.authInfo.state.redirect_uri,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
** Route to initiate SSO auth via Microsoft
|
||||
*/
|
||||
@Get('microsoft')
|
||||
@UseGuards(MicrosoftSSOGuard)
|
||||
async microsoftAuth(@Request() req) {}
|
||||
|
||||
/**
|
||||
** Callback URL for Microsoft SSO
|
||||
* @see https://auth0.com/docs/get-started/authentication-and-authorization-flow/authorization-code-flow#how-it-works
|
||||
*/
|
||||
@Get('microsoft/callback')
|
||||
@SkipThrottle()
|
||||
@UseGuards(MicrosoftSSOGuard)
|
||||
@UseInterceptors(UserLastLoginInterceptor)
|
||||
async microsoftAuthRedirect(@Request() req, @Res() res) {
|
||||
const authTokens = await this.authService.generateAuthTokens(req.user.uid);
|
||||
if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);
|
||||
authCookieHandler(
|
||||
res,
|
||||
authTokens.right,
|
||||
true,
|
||||
req.authInfo.state.redirect_uri,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
** Log user out by clearing cookies containing auth tokens
|
||||
*/
|
||||
@Get('logout')
|
||||
async logout(@Res() res: Response) {
|
||||
res.clearCookie('access_token');
|
||||
res.clearCookie('refresh_token');
|
||||
return res.status(200).send();
|
||||
}
|
||||
|
||||
@Get('verify/admin')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async verifyAdmin(@GqlUser() user: AuthUser) {
|
||||
const userInfo = await this.authService.verifyAdmin(user);
|
||||
if (E.isLeft(userInfo)) throwHTTPErr(userInfo.left);
|
||||
return userInfo.right;
|
||||
}
|
||||
}
|
||||
65
packages/hoppscotch-backend/src/auth/auth.module.ts
Normal file
65
packages/hoppscotch-backend/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { UserModule } from 'src/user/user.module';
|
||||
import { PrismaModule } from 'src/prisma/prisma.module';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { RTJwtStrategy } from './strategies/rt-jwt.strategy';
|
||||
import { GoogleStrategy } from './strategies/google.strategy';
|
||||
import { GithubStrategy } from './strategies/github.strategy';
|
||||
import { MicrosoftStrategy } from './strategies/microsoft.strategy';
|
||||
import { AuthProvider, authProviderCheck } from './helper';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
isInfraConfigTablePopulated,
|
||||
loadInfraConfiguration,
|
||||
} from 'src/infra-config/helper';
|
||||
import { InfraConfigModule } from 'src/infra-config/infra-config.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PrismaModule,
|
||||
UserModule,
|
||||
PassportModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: async (configService: ConfigService) => ({
|
||||
secret: configService.get('JWT_SECRET'),
|
||||
}),
|
||||
}),
|
||||
InfraConfigModule,
|
||||
],
|
||||
providers: [AuthService, JwtStrategy, RTJwtStrategy],
|
||||
controllers: [AuthController],
|
||||
})
|
||||
export class AuthModule {
|
||||
static async register() {
|
||||
const isInfraConfigPopulated = await isInfraConfigTablePopulated();
|
||||
if (!isInfraConfigPopulated) {
|
||||
return { module: AuthModule };
|
||||
}
|
||||
|
||||
const env = await loadInfraConfiguration();
|
||||
const allowedAuthProviders = env.INFRA.VITE_ALLOWED_AUTH_PROVIDERS;
|
||||
|
||||
const providers = [
|
||||
...(authProviderCheck(AuthProvider.GOOGLE, allowedAuthProviders)
|
||||
? [GoogleStrategy]
|
||||
: []),
|
||||
...(authProviderCheck(AuthProvider.GITHUB, allowedAuthProviders)
|
||||
? [GithubStrategy]
|
||||
: []),
|
||||
...(authProviderCheck(AuthProvider.MICROSOFT, allowedAuthProviders)
|
||||
? [MicrosoftStrategy]
|
||||
: []),
|
||||
];
|
||||
|
||||
return {
|
||||
module: AuthModule,
|
||||
providers,
|
||||
};
|
||||
}
|
||||
}
|
||||
431
packages/hoppscotch-backend/src/auth/auth.service.spec.ts
Normal file
431
packages/hoppscotch-backend/src/auth/auth.service.spec.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
import { HttpStatus } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Account, VerificationToken } from '@prisma/client';
|
||||
import { mockDeep, mockFn } from 'jest-mock-extended';
|
||||
import {
|
||||
INVALID_EMAIL,
|
||||
INVALID_MAGIC_LINK_DATA,
|
||||
INVALID_REFRESH_TOKEN,
|
||||
MAGIC_LINK_EXPIRED,
|
||||
VERIFICATION_TOKEN_DATA_NOT_FOUND,
|
||||
USER_NOT_FOUND,
|
||||
USERS_NOT_FOUND,
|
||||
} from 'src/errors';
|
||||
import { MailerService } from 'src/mailer/mailer.service';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { AuthUser } from 'src/types/AuthUser';
|
||||
import { UserService } from 'src/user/user.service';
|
||||
import { AuthService } from './auth.service';
|
||||
import * as O from 'fp-ts/Option';
|
||||
import { VerifyMagicDto } from './dto/verify-magic.dto';
|
||||
import { DateTime } from 'luxon';
|
||||
import * as argon2 from 'argon2';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InfraConfigService } from 'src/infra-config/infra-config.service';
|
||||
|
||||
const mockPrisma = mockDeep<PrismaService>();
|
||||
const mockUser = mockDeep<UserService>();
|
||||
const mockJWT = mockDeep<JwtService>();
|
||||
const mockMailer = mockDeep<MailerService>();
|
||||
const mockConfigService = mockDeep<ConfigService>();
|
||||
const mockInfraConfigService = mockDeep<InfraConfigService>();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const authService = new AuthService(
|
||||
mockUser,
|
||||
mockPrisma,
|
||||
mockJWT,
|
||||
mockMailer,
|
||||
mockConfigService,
|
||||
mockInfraConfigService,
|
||||
);
|
||||
|
||||
const currentTime = new Date();
|
||||
|
||||
const user: AuthUser = {
|
||||
uid: '123344',
|
||||
email: 'dwight@dundermifflin.com',
|
||||
displayName: 'Dwight Schrute',
|
||||
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
|
||||
isAdmin: false,
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: currentTime,
|
||||
lastActiveOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
currentGQLSession: {},
|
||||
currentRESTSession: {},
|
||||
};
|
||||
|
||||
const passwordlessData: VerificationToken = {
|
||||
deviceIdentifier: 'k23hb7u7gdcujhb',
|
||||
token: 'jhhj24sdjvl',
|
||||
userUid: user.uid,
|
||||
expiresOn: new Date(),
|
||||
};
|
||||
|
||||
const magicLinkVerify: VerifyMagicDto = {
|
||||
deviceIdentifier: 'Dscdc',
|
||||
token: 'SDcsdc',
|
||||
};
|
||||
|
||||
const accountDetails: Account = {
|
||||
id: '123dcdc',
|
||||
userId: user.uid,
|
||||
provider: 'email',
|
||||
providerAccountId: user.uid,
|
||||
providerRefreshToken: 'dscsdc',
|
||||
providerAccessToken: 'sdcsdcsdc',
|
||||
providerScope: 'user.email',
|
||||
loggedIn: currentTime,
|
||||
};
|
||||
|
||||
let nowPlus30 = new Date();
|
||||
nowPlus30.setMinutes(nowPlus30.getMinutes() + 30000);
|
||||
nowPlus30 = new Date(nowPlus30);
|
||||
|
||||
const encodedRefreshToken =
|
||||
'$argon2id$v=19$m=65536,t=3,p=4$JTP8yZ8YXMHdafb5pB9Rfg$tdZrILUxMb9dQbu0uuyeReLgKxsgYnyUNbc5ZxQmy5I';
|
||||
|
||||
describe('signInMagicLink', () => {
|
||||
test('Should throw error if email is not in valid format', async () => {
|
||||
const result = await authService.signInMagicLink('bbbgmail.com', 'admin');
|
||||
expect(result).toEqualLeft({
|
||||
message: INVALID_EMAIL,
|
||||
statusCode: HttpStatus.BAD_REQUEST,
|
||||
});
|
||||
});
|
||||
|
||||
test('Should successfully create a new user account and return the passwordless details', async () => {
|
||||
// check to see if user exists, return none
|
||||
mockUser.findUserByEmail.mockResolvedValue(O.none);
|
||||
// create new user
|
||||
mockUser.createUserViaMagicLink.mockResolvedValue(user);
|
||||
// create new entry in VerificationToken table
|
||||
mockPrisma.verificationToken.create.mockResolvedValueOnce(passwordlessData);
|
||||
// Read env variable 'MAGIC_LINK_TOKEN_VALIDITY' from config service
|
||||
mockConfigService.get.mockReturnValue('3');
|
||||
|
||||
const result = await authService.signInMagicLink(
|
||||
'dwight@dundermifflin.com',
|
||||
'admin',
|
||||
);
|
||||
expect(result).toEqualRight({
|
||||
deviceIdentifier: passwordlessData.deviceIdentifier,
|
||||
});
|
||||
});
|
||||
|
||||
test('Should successfully return the passwordless details for a pre-existing user account', async () => {
|
||||
// check to see if user exists, return error
|
||||
mockUser.findUserByEmail.mockResolvedValueOnce(O.some(user));
|
||||
// create new entry in VerificationToken table
|
||||
mockPrisma.verificationToken.create.mockResolvedValueOnce(passwordlessData);
|
||||
|
||||
const result = await authService.signInMagicLink(
|
||||
'dwight@dundermifflin.com',
|
||||
'admin',
|
||||
);
|
||||
expect(result).toEqualRight({
|
||||
deviceIdentifier: passwordlessData.deviceIdentifier,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyMagicLinkTokens', () => {
|
||||
test('Should throw INVALID_MAGIC_LINK_DATA if data is invalid', async () => {
|
||||
mockPrisma.verificationToken.findUniqueOrThrow.mockRejectedValueOnce(
|
||||
'NotFoundError',
|
||||
);
|
||||
|
||||
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
|
||||
expect(result).toEqualLeft({
|
||||
message: INVALID_MAGIC_LINK_DATA,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
});
|
||||
|
||||
test('Should throw USER_NOT_FOUND if user is invalid', async () => {
|
||||
// validatePasswordlessTokens
|
||||
mockPrisma.verificationToken.findUniqueOrThrow.mockResolvedValueOnce(
|
||||
passwordlessData,
|
||||
);
|
||||
// findUserById
|
||||
mockUser.findUserById.mockResolvedValue(O.none);
|
||||
|
||||
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
|
||||
expect(result).toEqualLeft({
|
||||
message: USER_NOT_FOUND,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
});
|
||||
|
||||
test('Should successfully return auth token pair with provider account existing', async () => {
|
||||
// validatePasswordlessTokens
|
||||
mockPrisma.verificationToken.findUniqueOrThrow.mockResolvedValueOnce({
|
||||
...passwordlessData,
|
||||
expiresOn: nowPlus30,
|
||||
});
|
||||
// findUserById
|
||||
mockUser.findUserById.mockResolvedValue(O.some(user));
|
||||
// checkIfProviderAccountExists
|
||||
mockPrisma.account.findUnique.mockResolvedValueOnce(accountDetails);
|
||||
// mockPrisma.account.findUnique.mockResolvedValueOnce(null);
|
||||
// generateAuthTokens
|
||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
||||
// UpdateUserRefreshToken
|
||||
mockUser.updateUserRefreshToken.mockResolvedValueOnce(E.right(user));
|
||||
// deletePasswordlessVerificationToken
|
||||
mockPrisma.verificationToken.delete.mockResolvedValueOnce(passwordlessData);
|
||||
// usersService.updateUserLastLoggedOn
|
||||
mockUser.updateUserLastLoggedOn.mockResolvedValue(E.right(true));
|
||||
|
||||
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
|
||||
expect(result).toEqualRight({
|
||||
access_token: user.refreshToken,
|
||||
refresh_token: user.refreshToken,
|
||||
});
|
||||
});
|
||||
|
||||
test('Should successfully return auth token pair with provider account not existing', async () => {
|
||||
// validatePasswordlessTokens
|
||||
mockPrisma.verificationToken.findUniqueOrThrow.mockResolvedValueOnce({
|
||||
...passwordlessData,
|
||||
expiresOn: nowPlus30,
|
||||
});
|
||||
// findUserById
|
||||
mockUser.findUserById.mockResolvedValue(O.some(user));
|
||||
// checkIfProviderAccountExists
|
||||
mockPrisma.account.findUnique.mockResolvedValueOnce(null);
|
||||
mockUser.createUserSSO.mockResolvedValueOnce(user);
|
||||
// generateAuthTokens
|
||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
||||
// UpdateUserRefreshToken
|
||||
mockUser.updateUserRefreshToken.mockResolvedValueOnce(E.right(user));
|
||||
// deletePasswordlessVerificationToken
|
||||
mockPrisma.verificationToken.delete.mockResolvedValueOnce(passwordlessData);
|
||||
// usersService.updateUserLastLoggedOn
|
||||
mockUser.updateUserLastLoggedOn.mockResolvedValue(E.right(true));
|
||||
|
||||
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
|
||||
expect(result).toEqualRight({
|
||||
access_token: user.refreshToken,
|
||||
refresh_token: user.refreshToken,
|
||||
});
|
||||
});
|
||||
|
||||
test('Should throw MAGIC_LINK_EXPIRED if passwordless token is expired', async () => {
|
||||
// validatePasswordlessTokens
|
||||
mockPrisma.verificationToken.findUniqueOrThrow.mockResolvedValueOnce(
|
||||
passwordlessData,
|
||||
);
|
||||
// findUserById
|
||||
mockUser.findUserById.mockResolvedValue(O.some(user));
|
||||
// checkIfProviderAccountExists
|
||||
mockPrisma.account.findUnique.mockResolvedValueOnce(accountDetails);
|
||||
|
||||
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
|
||||
expect(result).toEqualLeft({
|
||||
message: MAGIC_LINK_EXPIRED,
|
||||
statusCode: HttpStatus.UNAUTHORIZED,
|
||||
});
|
||||
});
|
||||
|
||||
test('Should throw USER_NOT_FOUND when updating refresh tokens fails', async () => {
|
||||
// validatePasswordlessTokens
|
||||
mockPrisma.verificationToken.findUniqueOrThrow.mockResolvedValueOnce({
|
||||
...passwordlessData,
|
||||
expiresOn: nowPlus30,
|
||||
});
|
||||
// findUserById
|
||||
mockUser.findUserById.mockResolvedValue(O.some(user));
|
||||
// checkIfProviderAccountExists
|
||||
mockPrisma.account.findUnique.mockResolvedValueOnce(accountDetails);
|
||||
// mockPrisma.account.findUnique.mockResolvedValueOnce(null);
|
||||
// generateAuthTokens
|
||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
||||
// UpdateUserRefreshToken
|
||||
mockUser.updateUserRefreshToken.mockResolvedValueOnce(
|
||||
E.left(USER_NOT_FOUND),
|
||||
);
|
||||
|
||||
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
|
||||
expect(result).toEqualLeft({
|
||||
message: USER_NOT_FOUND,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
});
|
||||
|
||||
test('Should throw PASSWORDLESS_DATA_NOT_FOUND when deleting passwordlessVerification entry from DB', async () => {
|
||||
// validatePasswordlessTokens
|
||||
mockPrisma.verificationToken.findUniqueOrThrow.mockResolvedValueOnce({
|
||||
...passwordlessData,
|
||||
expiresOn: nowPlus30,
|
||||
});
|
||||
// findUserById
|
||||
mockUser.findUserById.mockResolvedValue(O.some(user));
|
||||
// checkIfProviderAccountExists
|
||||
mockPrisma.account.findUnique.mockResolvedValueOnce(accountDetails);
|
||||
// mockPrisma.account.findUnique.mockResolvedValueOnce(null);
|
||||
// generateAuthTokens
|
||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
||||
// UpdateUserRefreshToken
|
||||
mockUser.updateUserRefreshToken.mockResolvedValueOnce(E.right(user));
|
||||
// deletePasswordlessVerificationToken
|
||||
mockPrisma.verificationToken.delete.mockRejectedValueOnce('RecordNotFound');
|
||||
|
||||
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
|
||||
expect(result).toEqualLeft({
|
||||
message: VERIFICATION_TOKEN_DATA_NOT_FOUND,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateAuthTokens', () => {
|
||||
test('Should successfully generate tokens with valid inputs', async () => {
|
||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
||||
// UpdateUserRefreshToken
|
||||
mockUser.updateUserRefreshToken.mockResolvedValueOnce(E.right(user));
|
||||
|
||||
const result = await authService.generateAuthTokens(user.uid);
|
||||
expect(result).toEqualRight({
|
||||
access_token: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
refresh_token: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
});
|
||||
});
|
||||
|
||||
test('Should throw USER_NOT_FOUND when updating refresh tokens fails', async () => {
|
||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
||||
// UpdateUserRefreshToken
|
||||
mockUser.updateUserRefreshToken.mockResolvedValueOnce(
|
||||
E.left(USER_NOT_FOUND),
|
||||
);
|
||||
|
||||
const result = await authService.generateAuthTokens(user.uid);
|
||||
expect(result).toEqualLeft({
|
||||
message: USER_NOT_FOUND,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
jest.mock('argon2', () => {
|
||||
return {
|
||||
verify: jest.fn((x, y) => {
|
||||
if (y === null) return false;
|
||||
return true;
|
||||
}),
|
||||
hash: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('refreshAuthTokens', () => {
|
||||
test('Should throw USER_NOT_FOUND when updating refresh tokens fails', async () => {
|
||||
// generateAuthTokens
|
||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
||||
// UpdateUserRefreshToken
|
||||
mockUser.updateUserRefreshToken.mockResolvedValueOnce(
|
||||
E.left(USER_NOT_FOUND),
|
||||
);
|
||||
|
||||
const result = await authService.refreshAuthTokens(
|
||||
'$argon2id$v=19$m=65536,t=3,p=4$MvVOam2clCOLtJFGEE26ZA$czvA5ez9hz+A/LML8QRgqgaFuWa5JcbwkH6r+imTQbs',
|
||||
user,
|
||||
);
|
||||
expect(result).toEqualLeft({
|
||||
message: USER_NOT_FOUND,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
});
|
||||
|
||||
test('Should throw USER_NOT_FOUND when user is invalid', async () => {
|
||||
const result = await authService.refreshAuthTokens(
|
||||
'jshdcbjsdhcbshdbc',
|
||||
null,
|
||||
);
|
||||
expect(result).toEqualLeft({
|
||||
message: USER_NOT_FOUND,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
});
|
||||
|
||||
test('Should successfully refresh the tokens and generate a new auth token pair', async () => {
|
||||
// generateAuthTokens
|
||||
mockJWT.sign.mockReturnValue('sdhjcbjsdhcbshjdcb');
|
||||
// UpdateUserRefreshToken
|
||||
mockUser.updateUserRefreshToken.mockResolvedValueOnce(
|
||||
E.right({
|
||||
...user,
|
||||
refreshToken: 'sdhjcbjsdhcbshjdcb',
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await authService.refreshAuthTokens(
|
||||
'$argon2id$v=19$m=65536,t=3,p=4$MvVOam2clCOLtJFGEE26ZA$czvA5ez9hz+A/LML8QRgqgaFuWa5JcbwkH6r+imTQbs',
|
||||
user,
|
||||
);
|
||||
expect(result).toEqualRight({
|
||||
access_token: 'sdhjcbjsdhcbshjdcb',
|
||||
refresh_token: 'sdhjcbjsdhcbshjdcb',
|
||||
});
|
||||
});
|
||||
|
||||
test('Should throw INVALID_REFRESH_TOKEN when the refresh token is invalid', async () => {
|
||||
// generateAuthTokens
|
||||
mockJWT.sign.mockReturnValue('sdhjcbjsdhcbshjdcb');
|
||||
mockPrisma.user.update.mockResolvedValueOnce({
|
||||
...user,
|
||||
refreshToken: 'sdhjcbjsdhcbshjdcb',
|
||||
});
|
||||
|
||||
const result = await authService.refreshAuthTokens(null, user);
|
||||
expect(result).toEqualLeft({
|
||||
message: INVALID_REFRESH_TOKEN,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyAdmin', () => {
|
||||
test('should successfully elevate user to admin when userCount is 1 ', async () => {
|
||||
// getUsersCount
|
||||
mockUser.getUsersCount.mockResolvedValueOnce(1);
|
||||
// makeAdmin
|
||||
mockUser.makeAdmin.mockResolvedValueOnce(
|
||||
E.right({
|
||||
...user,
|
||||
isAdmin: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await authService.verifyAdmin(user);
|
||||
expect(result).toEqualRight({ isAdmin: true });
|
||||
});
|
||||
|
||||
test('should return true if user is already an admin', async () => {
|
||||
const result = await authService.verifyAdmin({ ...user, isAdmin: true });
|
||||
expect(result).toEqualRight({ isAdmin: true });
|
||||
});
|
||||
|
||||
test('should throw USERS_NOT_FOUND when userUid is invalid', async () => {
|
||||
// getUsersCount
|
||||
mockUser.getUsersCount.mockResolvedValueOnce(1);
|
||||
// makeAdmin
|
||||
mockUser.makeAdmin.mockResolvedValueOnce(E.left(USER_NOT_FOUND));
|
||||
|
||||
const result = await authService.verifyAdmin(user);
|
||||
expect(result).toEqualLeft({
|
||||
message: USER_NOT_FOUND,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
});
|
||||
|
||||
test('should return false when user is not an admin and userCount is greater than 1', async () => {
|
||||
// getUsersCount
|
||||
mockUser.getUsersCount.mockResolvedValueOnce(13);
|
||||
|
||||
const result = await authService.verifyAdmin(user);
|
||||
expect(result).toEqualRight({ isAdmin: false });
|
||||
});
|
||||
});
|
||||
392
packages/hoppscotch-backend/src/auth/auth.service.ts
Normal file
392
packages/hoppscotch-backend/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { MailerService } from 'src/mailer/mailer.service';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { UserService } from 'src/user/user.service';
|
||||
import { VerifyMagicDto } from './dto/verify-magic.dto';
|
||||
import { DateTime } from 'luxon';
|
||||
import * as argon2 from 'argon2';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import * as O from 'fp-ts/Option';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import { DeviceIdentifierToken } from 'src/types/Passwordless';
|
||||
import {
|
||||
INVALID_EMAIL,
|
||||
INVALID_MAGIC_LINK_DATA,
|
||||
VERIFICATION_TOKEN_DATA_NOT_FOUND,
|
||||
MAGIC_LINK_EXPIRED,
|
||||
USER_NOT_FOUND,
|
||||
INVALID_REFRESH_TOKEN,
|
||||
} from 'src/errors';
|
||||
import { validateEmail } from 'src/utils';
|
||||
import {
|
||||
AccessTokenPayload,
|
||||
AuthTokens,
|
||||
RefreshTokenPayload,
|
||||
} from 'src/types/AuthTokens';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { RESTError } from 'src/types/RESTError';
|
||||
import { AuthUser, IsAdmin } from 'src/types/AuthUser';
|
||||
import { VerificationToken } from '@prisma/client';
|
||||
import { Origin } from './helper';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InfraConfigService } from 'src/infra-config/infra-config.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private usersService: UserService,
|
||||
private prismaService: PrismaService,
|
||||
private jwtService: JwtService,
|
||||
private readonly mailerService: MailerService,
|
||||
private readonly configService: ConfigService,
|
||||
private infraConfigService: InfraConfigService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Generate Id and token for email Magic-Link auth
|
||||
*
|
||||
* @param user User Object
|
||||
* @returns Created VerificationToken token
|
||||
*/
|
||||
private async generateMagicLinkTokens(user: AuthUser) {
|
||||
const salt = await bcrypt.genSalt(
|
||||
parseInt(this.configService.get('TOKEN_SALT_COMPLEXITY')),
|
||||
);
|
||||
const expiresOn = DateTime.now()
|
||||
.plus({
|
||||
hours: parseInt(this.configService.get('MAGIC_LINK_TOKEN_VALIDITY')),
|
||||
})
|
||||
.toISO()
|
||||
.toString();
|
||||
|
||||
const idToken = await this.prismaService.verificationToken.create({
|
||||
data: {
|
||||
deviceIdentifier: salt,
|
||||
userUid: user.uid,
|
||||
expiresOn: expiresOn,
|
||||
},
|
||||
});
|
||||
|
||||
return idToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if VerificationToken exist or not
|
||||
*
|
||||
* @param magicLinkTokens Object containing deviceIdentifier and token
|
||||
* @returns Option of VerificationToken token
|
||||
*/
|
||||
private async validatePasswordlessTokens(magicLinkTokens: VerifyMagicDto) {
|
||||
try {
|
||||
const tokens =
|
||||
await this.prismaService.verificationToken.findUniqueOrThrow({
|
||||
where: {
|
||||
passwordless_deviceIdentifier_tokens: {
|
||||
deviceIdentifier: magicLinkTokens.deviceIdentifier,
|
||||
token: magicLinkTokens.token,
|
||||
},
|
||||
},
|
||||
});
|
||||
return O.some(tokens);
|
||||
} catch (error) {
|
||||
return O.none;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate new refresh token for user
|
||||
*
|
||||
* @param userUid User Id
|
||||
* @returns Generated refreshToken
|
||||
*/
|
||||
private async generateRefreshToken(userUid: string) {
|
||||
const refreshTokenPayload: RefreshTokenPayload = {
|
||||
iss: this.configService.get('VITE_BASE_URL'),
|
||||
sub: userUid,
|
||||
aud: [this.configService.get('VITE_BASE_URL')],
|
||||
};
|
||||
|
||||
const refreshToken = await this.jwtService.sign(refreshTokenPayload, {
|
||||
expiresIn: this.configService.get('REFRESH_TOKEN_VALIDITY'), //7 Days
|
||||
});
|
||||
|
||||
const refreshTokenHash = await argon2.hash(refreshToken);
|
||||
|
||||
const updatedUser = await this.usersService.updateUserRefreshToken(
|
||||
refreshTokenHash,
|
||||
userUid,
|
||||
);
|
||||
if (E.isLeft(updatedUser))
|
||||
return E.left(<RESTError>{
|
||||
message: updatedUser.left,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
|
||||
return E.right(refreshToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate access and refresh token pair
|
||||
*
|
||||
* @param userUid User ID
|
||||
* @returns Either of generated AuthTokens
|
||||
*/
|
||||
async generateAuthTokens(userUid: string) {
|
||||
const accessTokenPayload: AccessTokenPayload = {
|
||||
iss: this.configService.get('VITE_BASE_URL'),
|
||||
sub: userUid,
|
||||
aud: [this.configService.get('VITE_BASE_URL')],
|
||||
};
|
||||
|
||||
const refreshToken = await this.generateRefreshToken(userUid);
|
||||
if (E.isLeft(refreshToken)) return E.left(refreshToken.left);
|
||||
|
||||
return E.right(<AuthTokens>{
|
||||
access_token: await this.jwtService.sign(accessTokenPayload, {
|
||||
expiresIn: this.configService.get('ACCESS_TOKEN_VALIDITY'), //1 Day
|
||||
}),
|
||||
refresh_token: refreshToken.right,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deleted used VerificationToken tokens
|
||||
*
|
||||
* @param passwordlessTokens VerificationToken entry to delete from DB
|
||||
* @returns Either of deleted VerificationToken token
|
||||
*/
|
||||
private async deleteMagicLinkVerificationTokens(
|
||||
passwordlessTokens: VerificationToken,
|
||||
) {
|
||||
try {
|
||||
const deletedPasswordlessToken =
|
||||
await this.prismaService.verificationToken.delete({
|
||||
where: {
|
||||
passwordless_deviceIdentifier_tokens: {
|
||||
deviceIdentifier: passwordlessTokens.deviceIdentifier,
|
||||
token: passwordlessTokens.token,
|
||||
},
|
||||
},
|
||||
});
|
||||
return E.right(deletedPasswordlessToken);
|
||||
} catch (error) {
|
||||
return E.left(VERIFICATION_TOKEN_DATA_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify if Provider account exists for User
|
||||
*
|
||||
* @param user User Object
|
||||
* @param SSOUserData User data from SSO providers (Magic,Google,Github,Microsoft)
|
||||
* @returns Either of existing user provider Account
|
||||
*/
|
||||
async checkIfProviderAccountExists(user: AuthUser, SSOUserData) {
|
||||
const provider = await this.prismaService.account.findUnique({
|
||||
where: {
|
||||
verifyProviderAccount: {
|
||||
provider: SSOUserData.provider,
|
||||
providerAccountId: SSOUserData.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!provider) return O.none;
|
||||
|
||||
return O.some(provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create User (if not already present) and send email to initiate Magic-Link auth
|
||||
*
|
||||
* @param email User's email
|
||||
* @returns Either containing DeviceIdentifierToken
|
||||
*/
|
||||
async signInMagicLink(email: string, origin: string) {
|
||||
if (!validateEmail(email))
|
||||
return E.left({
|
||||
message: INVALID_EMAIL,
|
||||
statusCode: HttpStatus.BAD_REQUEST,
|
||||
});
|
||||
|
||||
let user: AuthUser;
|
||||
const queriedUser = await this.usersService.findUserByEmail(email);
|
||||
|
||||
if (O.isNone(queriedUser)) {
|
||||
user = await this.usersService.createUserViaMagicLink(email);
|
||||
} else {
|
||||
user = queriedUser.value;
|
||||
}
|
||||
|
||||
const generatedTokens = await this.generateMagicLinkTokens(user);
|
||||
|
||||
// check to see if origin is valid
|
||||
let url: string;
|
||||
switch (origin) {
|
||||
case Origin.ADMIN:
|
||||
url = this.configService.get('VITE_ADMIN_URL');
|
||||
break;
|
||||
case Origin.APP:
|
||||
url = this.configService.get('VITE_BASE_URL');
|
||||
break;
|
||||
default:
|
||||
// if origin is invalid by default set URL to Hoppscotch-App
|
||||
url = this.configService.get('VITE_BASE_URL');
|
||||
}
|
||||
|
||||
await this.mailerService.sendEmail(email, {
|
||||
template: 'user-invitation',
|
||||
variables: {
|
||||
inviteeEmail: email,
|
||||
magicLink: `${url}/enter?token=${generatedTokens.token}`,
|
||||
},
|
||||
});
|
||||
|
||||
return E.right(<DeviceIdentifierToken>{
|
||||
deviceIdentifier: generatedTokens.deviceIdentifier,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify and authenticate user from received data for Magic-Link
|
||||
*
|
||||
* @param magicLinkIDTokens magic-link verification tokens from client
|
||||
* @returns Either of generated AuthTokens
|
||||
*/
|
||||
async verifyMagicLinkTokens(
|
||||
magicLinkIDTokens: VerifyMagicDto,
|
||||
): Promise<E.Right<AuthTokens> | E.Left<RESTError>> {
|
||||
const passwordlessTokens = await this.validatePasswordlessTokens(
|
||||
magicLinkIDTokens,
|
||||
);
|
||||
if (O.isNone(passwordlessTokens))
|
||||
return E.left({
|
||||
message: INVALID_MAGIC_LINK_DATA,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
|
||||
const user = await this.usersService.findUserById(
|
||||
passwordlessTokens.value.userUid,
|
||||
);
|
||||
if (O.isNone(user))
|
||||
return E.left({
|
||||
message: USER_NOT_FOUND,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
|
||||
/**
|
||||
* * Check to see if entry for Magic-Link is present in the Account table for user
|
||||
* * If user was created with another provider findUserById may return true
|
||||
*/
|
||||
const profile = {
|
||||
provider: 'magic',
|
||||
id: user.value.email,
|
||||
};
|
||||
const providerAccountExists = await this.checkIfProviderAccountExists(
|
||||
user.value,
|
||||
profile,
|
||||
);
|
||||
|
||||
if (O.isNone(providerAccountExists)) {
|
||||
await this.usersService.createProviderAccount(
|
||||
user.value,
|
||||
null,
|
||||
null,
|
||||
profile,
|
||||
);
|
||||
}
|
||||
|
||||
const currentTime = DateTime.now().toISO();
|
||||
if (currentTime > passwordlessTokens.value.expiresOn.toISOString())
|
||||
return E.left({
|
||||
message: MAGIC_LINK_EXPIRED,
|
||||
statusCode: HttpStatus.UNAUTHORIZED,
|
||||
});
|
||||
|
||||
const tokens = await this.generateAuthTokens(
|
||||
passwordlessTokens.value.userUid,
|
||||
);
|
||||
if (E.isLeft(tokens))
|
||||
return E.left({
|
||||
message: tokens.left.message,
|
||||
statusCode: tokens.left.statusCode,
|
||||
});
|
||||
|
||||
const deletedPasswordlessToken =
|
||||
await this.deleteMagicLinkVerificationTokens(passwordlessTokens.value);
|
||||
if (E.isLeft(deletedPasswordlessToken))
|
||||
return E.left({
|
||||
message: deletedPasswordlessToken.left,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
|
||||
this.usersService.updateUserLastLoggedOn(passwordlessTokens.value.userUid);
|
||||
|
||||
return E.right(tokens.right);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh refresh and auth tokens
|
||||
*
|
||||
* @param hashedRefreshToken Hashed refresh token received from client
|
||||
* @param user User Object
|
||||
* @returns Either of generated AuthTokens
|
||||
*/
|
||||
async refreshAuthTokens(hashedRefreshToken: string, user: AuthUser) {
|
||||
// Check to see user is valid
|
||||
if (!user)
|
||||
return E.left({
|
||||
message: USER_NOT_FOUND,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
|
||||
// Check to see if the hashed refresh_token received from the client is the same as the refresh_token saved in the DB
|
||||
const isTokenMatched = await argon2.verify(
|
||||
user.refreshToken,
|
||||
hashedRefreshToken,
|
||||
);
|
||||
if (!isTokenMatched)
|
||||
return E.left({
|
||||
message: INVALID_REFRESH_TOKEN,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
|
||||
// if tokens match, generate new pair of auth tokens
|
||||
const generatedAuthTokens = await this.generateAuthTokens(user.uid);
|
||||
if (E.isLeft(generatedAuthTokens))
|
||||
return E.left({
|
||||
message: generatedAuthTokens.left.message,
|
||||
statusCode: generatedAuthTokens.left.statusCode,
|
||||
});
|
||||
|
||||
return E.right(generatedAuthTokens.right);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify is signed in User is an admin or not
|
||||
*
|
||||
* @param user User Object
|
||||
* @returns Either of boolean if user is admin or not
|
||||
*/
|
||||
async verifyAdmin(user: AuthUser) {
|
||||
if (user.isAdmin) return E.right(<IsAdmin>{ isAdmin: true });
|
||||
|
||||
const usersCount = await this.usersService.getUsersCount();
|
||||
if (usersCount === 1) {
|
||||
const elevatedUser = await this.usersService.makeAdmin(user.uid);
|
||||
if (E.isLeft(elevatedUser))
|
||||
return E.left(<RESTError>{
|
||||
message: elevatedUser.left,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
|
||||
return E.right(<IsAdmin>{ isAdmin: true });
|
||||
}
|
||||
|
||||
return E.right(<IsAdmin>{ isAdmin: false });
|
||||
}
|
||||
|
||||
getAuthProviders() {
|
||||
return this.infraConfigService.getAllowedAuthProviders();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Inputs to initiate Magic-Link auth flow
|
||||
export class SignInMagicDto {
|
||||
email: string;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Inputs to verify and sign a user in via magic-link
|
||||
export class VerifyMagicDto {
|
||||
deviceIdentifier: string;
|
||||
token: string;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { AuthProvider, authProviderCheck } from '../helper';
|
||||
import { Observable } from 'rxjs';
|
||||
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { throwHTTPErr } from 'src/utils';
|
||||
|
||||
@Injectable()
|
||||
export class GithubSSOGuard extends AuthGuard('github') implements CanActivate {
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
super();
|
||||
}
|
||||
|
||||
canActivate(
|
||||
context: ExecutionContext,
|
||||
): boolean | Promise<boolean> | Observable<boolean> {
|
||||
if (
|
||||
!authProviderCheck(
|
||||
AuthProvider.GITHUB,
|
||||
this.configService.get('INFRA.VITE_ALLOWED_AUTH_PROVIDERS'),
|
||||
)
|
||||
) {
|
||||
throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 });
|
||||
}
|
||||
|
||||
return super.canActivate(context);
|
||||
}
|
||||
|
||||
getAuthenticateOptions(context: ExecutionContext) {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
|
||||
return {
|
||||
state: {
|
||||
redirect_uri: req.query.redirect_uri,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { AuthProvider, authProviderCheck } from '../helper';
|
||||
import { Observable } from 'rxjs';
|
||||
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { throwHTTPErr } from 'src/utils';
|
||||
|
||||
@Injectable()
|
||||
export class GoogleSSOGuard extends AuthGuard('google') implements CanActivate {
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
super();
|
||||
}
|
||||
|
||||
canActivate(
|
||||
context: ExecutionContext,
|
||||
): boolean | Promise<boolean> | Observable<boolean> {
|
||||
if (
|
||||
!authProviderCheck(
|
||||
AuthProvider.GOOGLE,
|
||||
this.configService.get('INFRA.VITE_ALLOWED_AUTH_PROVIDERS'),
|
||||
)
|
||||
) {
|
||||
throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 });
|
||||
}
|
||||
|
||||
return super.canActivate(context);
|
||||
}
|
||||
|
||||
getAuthenticateOptions(context: ExecutionContext) {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
|
||||
return {
|
||||
state: {
|
||||
redirect_uri: req.query.redirect_uri,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { AuthProvider, authProviderCheck } from '../helper';
|
||||
import { Observable } from 'rxjs';
|
||||
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { throwHTTPErr } from 'src/utils';
|
||||
|
||||
@Injectable()
|
||||
export class MicrosoftSSOGuard
|
||||
extends AuthGuard('microsoft')
|
||||
implements CanActivate
|
||||
{
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
super();
|
||||
}
|
||||
|
||||
canActivate(
|
||||
context: ExecutionContext,
|
||||
): boolean | Promise<boolean> | Observable<boolean> {
|
||||
if (
|
||||
!authProviderCheck(
|
||||
AuthProvider.MICROSOFT,
|
||||
this.configService.get('INFRA.VITE_ALLOWED_AUTH_PROVIDERS'),
|
||||
)
|
||||
) {
|
||||
throwHTTPErr({
|
||||
message: AUTH_PROVIDER_NOT_SPECIFIED,
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
return super.canActivate(context);
|
||||
}
|
||||
|
||||
getAuthenticateOptions(context: ExecutionContext) {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
|
||||
return {
|
||||
state: {
|
||||
redirect_uri: req.query.redirect_uri,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class RTJwtAuthGuard extends AuthGuard('jwt-refresh') {}
|
||||
127
packages/hoppscotch-backend/src/auth/helper.ts
Normal file
127
packages/hoppscotch-backend/src/auth/helper.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { DateTime } from 'luxon';
|
||||
import { AuthTokens } from 'src/types/AuthTokens';
|
||||
import { Response } from 'express';
|
||||
import * as cookie from 'cookie';
|
||||
import { AUTH_PROVIDER_NOT_SPECIFIED, COOKIES_NOT_FOUND } from 'src/errors';
|
||||
import { throwErr } from 'src/utils';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
enum AuthTokenType {
|
||||
ACCESS_TOKEN = 'access_token',
|
||||
REFRESH_TOKEN = 'refresh_token',
|
||||
}
|
||||
|
||||
export enum Origin {
|
||||
ADMIN = 'admin',
|
||||
APP = 'app',
|
||||
}
|
||||
|
||||
export enum AuthProvider {
|
||||
GOOGLE = 'GOOGLE',
|
||||
GITHUB = 'GITHUB',
|
||||
MICROSOFT = 'MICROSOFT',
|
||||
EMAIL = 'EMAIL',
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets and returns the cookies in the response object on successful authentication
|
||||
* @param res Express Response Object
|
||||
* @param authTokens Object containing the access and refresh tokens
|
||||
* @param redirect if true will redirect to provided URL else just send a 200 status code
|
||||
*/
|
||||
export const authCookieHandler = (
|
||||
res: Response,
|
||||
authTokens: AuthTokens,
|
||||
redirect: boolean,
|
||||
redirectUrl: string | null,
|
||||
) => {
|
||||
const configService = new ConfigService();
|
||||
|
||||
const currentTime = DateTime.now();
|
||||
const accessTokenValidity = currentTime
|
||||
.plus({
|
||||
milliseconds: parseInt(configService.get('ACCESS_TOKEN_VALIDITY')),
|
||||
})
|
||||
.toMillis();
|
||||
const refreshTokenValidity = currentTime
|
||||
.plus({
|
||||
milliseconds: parseInt(configService.get('REFRESH_TOKEN_VALIDITY')),
|
||||
})
|
||||
.toMillis();
|
||||
|
||||
res.cookie(AuthTokenType.ACCESS_TOKEN, authTokens.access_token, {
|
||||
httpOnly: true,
|
||||
secure: configService.get('ALLOW_SECURE_COOKIES') === 'true',
|
||||
sameSite: 'lax',
|
||||
maxAge: accessTokenValidity,
|
||||
});
|
||||
res.cookie(AuthTokenType.REFRESH_TOKEN, authTokens.refresh_token, {
|
||||
httpOnly: true,
|
||||
secure: configService.get('ALLOW_SECURE_COOKIES') === 'true',
|
||||
sameSite: 'lax',
|
||||
maxAge: refreshTokenValidity,
|
||||
});
|
||||
|
||||
if (!redirect) {
|
||||
return res.status(HttpStatus.OK).send();
|
||||
}
|
||||
|
||||
// check to see if redirectUrl is a whitelisted url
|
||||
const whitelistedOrigins = configService
|
||||
.get('WHITELISTED_ORIGINS')
|
||||
.split(',');
|
||||
if (!whitelistedOrigins.includes(redirectUrl))
|
||||
// if it is not redirect by default to REDIRECT_URL
|
||||
redirectUrl = configService.get('REDIRECT_URL');
|
||||
|
||||
return res.status(HttpStatus.OK).redirect(redirectUrl);
|
||||
};
|
||||
|
||||
/**
|
||||
* Decode the cookie header from incoming websocket connects and returns a auth token pair
|
||||
* @param rawCookies cookies from the websocket connection
|
||||
* @returns AuthTokens for JWT strategy to use
|
||||
*/
|
||||
export const subscriptionContextCookieParser = (rawCookies: string) => {
|
||||
const cookies = cookie.parse(rawCookies);
|
||||
|
||||
if (
|
||||
!cookies[AuthTokenType.ACCESS_TOKEN] &&
|
||||
!cookies[AuthTokenType.REFRESH_TOKEN]
|
||||
) {
|
||||
throw new HttpException(COOKIES_NOT_FOUND, 400, {
|
||||
cause: new Error(COOKIES_NOT_FOUND),
|
||||
});
|
||||
}
|
||||
|
||||
return <AuthTokens>{
|
||||
access_token: cookies[AuthTokenType.ACCESS_TOKEN],
|
||||
refresh_token: cookies[AuthTokenType.REFRESH_TOKEN],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Check to see if given auth provider is present in the VITE_ALLOWED_AUTH_PROVIDERS env variable
|
||||
*
|
||||
* @param provider Provider we want to check the presence of
|
||||
* @returns Boolean if provider specified is present or not
|
||||
*/
|
||||
export function authProviderCheck(
|
||||
provider: string,
|
||||
VITE_ALLOWED_AUTH_PROVIDERS: string,
|
||||
) {
|
||||
if (!provider) {
|
||||
throwErr(AUTH_PROVIDER_NOT_SPECIFIED);
|
||||
}
|
||||
|
||||
const envVariables = VITE_ALLOWED_AUTH_PROVIDERS
|
||||
? VITE_ALLOWED_AUTH_PROVIDERS.split(',').map((provider) =>
|
||||
provider.trim().toUpperCase(),
|
||||
)
|
||||
: [];
|
||||
|
||||
if (!envVariables.includes(provider.toUpperCase())) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Strategy } from 'passport-github2';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthService } from '../auth.service';
|
||||
import { UserService } from 'src/user/user.service';
|
||||
import * as O from 'fp-ts/Option';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class GithubStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private usersService: UserService,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
super({
|
||||
clientID: configService.get('INFRA.GITHUB_CLIENT_ID'),
|
||||
clientSecret: configService.get('INFRA.GITHUB_CLIENT_SECRET'),
|
||||
callbackURL: configService.get('INFRA.GITHUB_CALLBACK_URL'),
|
||||
scope: [configService.get('INFRA.GITHUB_SCOPE')],
|
||||
store: true,
|
||||
});
|
||||
}
|
||||
|
||||
async validate(accessToken, refreshToken, profile, done) {
|
||||
const user = await this.usersService.findUserByEmail(
|
||||
profile.emails[0].value,
|
||||
);
|
||||
|
||||
if (O.isNone(user)) {
|
||||
const createdUser = await this.usersService.createUserSSO(
|
||||
accessToken,
|
||||
refreshToken,
|
||||
profile,
|
||||
);
|
||||
return createdUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* * displayName and photoURL maybe null if user logged-in via magic-link before SSO
|
||||
*/
|
||||
if (!user.value.displayName || !user.value.photoURL) {
|
||||
const updatedUser = await this.usersService.updateUserDetails(
|
||||
user.value,
|
||||
profile,
|
||||
);
|
||||
if (E.isLeft(updatedUser)) {
|
||||
throw new UnauthorizedException(updatedUser.left);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* * Check to see if entry for Github is present in the Account table for user
|
||||
* * If user was created with another provider findUserByEmail may return true
|
||||
*/
|
||||
const providerAccountExists =
|
||||
await this.authService.checkIfProviderAccountExists(user.value, profile);
|
||||
|
||||
if (O.isNone(providerAccountExists))
|
||||
await this.usersService.createProviderAccount(
|
||||
user.value,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
profile,
|
||||
);
|
||||
|
||||
return user.value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Strategy, VerifyCallback } from 'passport-google-oauth20';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { UserService } from 'src/user/user.service';
|
||||
import * as O from 'fp-ts/Option';
|
||||
import { AuthService } from '../auth.service';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class GoogleStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
private usersService: UserService,
|
||||
private authService: AuthService,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
super({
|
||||
clientID: configService.get('INFRA.GOOGLE_CLIENT_ID'),
|
||||
clientSecret: configService.get('INFRA.GOOGLE_CLIENT_SECRET'),
|
||||
callbackURL: configService.get('INFRA.GOOGLE_CALLBACK_URL'),
|
||||
scope: configService.get('INFRA.GOOGLE_SCOPE').split(','),
|
||||
passReqToCallback: true,
|
||||
store: true,
|
||||
});
|
||||
}
|
||||
|
||||
async validate(
|
||||
req: Request,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
profile,
|
||||
done: VerifyCallback,
|
||||
) {
|
||||
const user = await this.usersService.findUserByEmail(
|
||||
profile.emails[0].value,
|
||||
);
|
||||
|
||||
if (O.isNone(user)) {
|
||||
const createdUser = await this.usersService.createUserSSO(
|
||||
accessToken,
|
||||
refreshToken,
|
||||
profile,
|
||||
);
|
||||
return createdUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* * displayName and photoURL maybe null if user logged-in via magic-link before SSO
|
||||
*/
|
||||
if (!user.value.displayName || !user.value.photoURL) {
|
||||
const updatedUser = await this.usersService.updateUserDetails(
|
||||
user.value,
|
||||
profile,
|
||||
);
|
||||
if (E.isLeft(updatedUser)) {
|
||||
throw new UnauthorizedException(updatedUser.left);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* * Check to see if entry for Google is present in the Account table for user
|
||||
* * If user was created with another provider findUserByEmail may return true
|
||||
*/
|
||||
const providerAccountExists =
|
||||
await this.authService.checkIfProviderAccountExists(user.value, profile);
|
||||
|
||||
if (O.isNone(providerAccountExists))
|
||||
await this.usersService.createProviderAccount(
|
||||
user.value,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
profile,
|
||||
);
|
||||
|
||||
return user.value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import {
|
||||
Injectable,
|
||||
ForbiddenException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { AccessTokenPayload } from 'src/types/AuthTokens';
|
||||
import { UserService } from 'src/user/user.service';
|
||||
import { AuthService } from '../auth.service';
|
||||
import { Request } from 'express';
|
||||
import * as O from 'fp-ts/Option';
|
||||
import {
|
||||
COOKIES_NOT_FOUND,
|
||||
INVALID_ACCESS_TOKEN,
|
||||
USER_NOT_FOUND,
|
||||
} from 'src/errors';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
constructor(
|
||||
private usersService: UserService,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromExtractors([
|
||||
(request: Request) => {
|
||||
const ATCookie = request.cookies['access_token'];
|
||||
if (!ATCookie) {
|
||||
throw new ForbiddenException(COOKIES_NOT_FOUND);
|
||||
}
|
||||
return ATCookie;
|
||||
},
|
||||
]),
|
||||
secretOrKey: configService.get('JWT_SECRET'),
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: AccessTokenPayload) {
|
||||
if (!payload) throw new ForbiddenException(INVALID_ACCESS_TOKEN);
|
||||
|
||||
const user = await this.usersService.findUserById(payload.sub);
|
||||
if (O.isNone(user)) {
|
||||
throw new UnauthorizedException(USER_NOT_FOUND);
|
||||
}
|
||||
|
||||
return user.value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Strategy } from 'passport-microsoft';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthService } from '../auth.service';
|
||||
import { UserService } from 'src/user/user.service';
|
||||
import * as O from 'fp-ts/Option';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class MicrosoftStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private usersService: UserService,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
super({
|
||||
clientID: configService.get('INFRA.MICROSOFT_CLIENT_ID'),
|
||||
clientSecret: configService.get('INFRA.MICROSOFT_CLIENT_SECRET'),
|
||||
callbackURL: configService.get('INFRA.MICROSOFT_CALLBACK_URL'),
|
||||
scope: [configService.get('INFRA.MICROSOFT_SCOPE')],
|
||||
tenant: configService.get('INFRA.MICROSOFT_TENANT'),
|
||||
store: true,
|
||||
});
|
||||
}
|
||||
|
||||
async validate(accessToken: string, refreshToken: string, profile, done) {
|
||||
const user = await this.usersService.findUserByEmail(
|
||||
profile.emails[0].value,
|
||||
);
|
||||
|
||||
if (O.isNone(user)) {
|
||||
const createdUser = await this.usersService.createUserSSO(
|
||||
accessToken,
|
||||
refreshToken,
|
||||
profile,
|
||||
);
|
||||
return createdUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* * displayName and photoURL maybe null if user logged-in via magic-link before SSO
|
||||
*/
|
||||
if (!user.value.displayName || !user.value.photoURL) {
|
||||
const updatedUser = await this.usersService.updateUserDetails(
|
||||
user.value,
|
||||
profile,
|
||||
);
|
||||
if (E.isLeft(updatedUser)) {
|
||||
throw new UnauthorizedException(updatedUser.left);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* * Check to see if entry for Microsoft is present in the Account table for user
|
||||
* * If user was created with another provider findUserByEmail may return true
|
||||
*/
|
||||
const providerAccountExists =
|
||||
await this.authService.checkIfProviderAccountExists(user.value, profile);
|
||||
|
||||
if (O.isNone(providerAccountExists))
|
||||
await this.usersService.createProviderAccount(
|
||||
user.value,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
profile,
|
||||
);
|
||||
|
||||
return user.value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import {
|
||||
Injectable,
|
||||
ForbiddenException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { UserService } from 'src/user/user.service';
|
||||
import { Request } from 'express';
|
||||
import { RefreshTokenPayload } from 'src/types/AuthTokens';
|
||||
import {
|
||||
COOKIES_NOT_FOUND,
|
||||
INVALID_REFRESH_TOKEN,
|
||||
USER_NOT_FOUND,
|
||||
} from 'src/errors';
|
||||
import * as O from 'fp-ts/Option';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class RTJwtStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
|
||||
constructor(
|
||||
private usersService: UserService,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromExtractors([
|
||||
(request: Request) => {
|
||||
const RTCookie = request.cookies['refresh_token'];
|
||||
if (!RTCookie) {
|
||||
throw new ForbiddenException(COOKIES_NOT_FOUND);
|
||||
}
|
||||
return RTCookie;
|
||||
},
|
||||
]),
|
||||
secretOrKey: configService.get('JWT_SECRET'),
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: RefreshTokenPayload) {
|
||||
if (!payload) throw new ForbiddenException(INVALID_REFRESH_TOKEN);
|
||||
|
||||
const user = await this.usersService.findUserById(payload.sub);
|
||||
if (O.isNone(user)) {
|
||||
throw new UnauthorizedException(USER_NOT_FOUND);
|
||||
}
|
||||
|
||||
return user.value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
** Decorator to fetch refresh_token from cookie
|
||||
*/
|
||||
export const BearerToken = createParamDecorator(
|
||||
(data: unknown, context: ExecutionContext) => {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
|
||||
// authorization token will be "Bearer <token>"
|
||||
const authorization = request.headers['authorization'];
|
||||
// Remove "Bearer " and return the token only
|
||||
return authorization.split(' ')[1];
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { GqlExecutionContext } from '@nestjs/graphql';
|
||||
|
||||
export const GqlUser = createParamDecorator(
|
||||
(data: unknown, context: ExecutionContext) => {
|
||||
const ctx = GqlExecutionContext.create(context);
|
||||
const { req, headers } = ctx.getContext();
|
||||
return headers ? headers.user : req.user;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { GqlExecutionContext } from '@nestjs/graphql';
|
||||
|
||||
/**
|
||||
** Decorator to fetch refresh_token from cookie
|
||||
*/
|
||||
export const RTCookie = createParamDecorator(
|
||||
(data: unknown, context: ExecutionContext) => {
|
||||
const ctx = GqlExecutionContext.create(context);
|
||||
return ctx.getContext().req.cookies['refresh_token'];
|
||||
},
|
||||
);
|
||||
855
packages/hoppscotch-backend/src/errors.ts
Normal file
855
packages/hoppscotch-backend/src/errors.ts
Normal file
@@ -0,0 +1,855 @@
|
||||
export const INVALID_EMAIL = 'invalid/email' as const;
|
||||
|
||||
export const EMAIL_FAILED = 'email/failed' as const;
|
||||
export const DUPLICATE_EMAIL = 'email/both_emails_cannot_be_same' as const;
|
||||
|
||||
/**
|
||||
* Only one admin account found in infra
|
||||
* (AdminService)
|
||||
*/
|
||||
export const ONLY_ONE_ADMIN_ACCOUNT =
|
||||
'admin/only_one_admin_account_found' as const;
|
||||
|
||||
/**
|
||||
* Admin user can not be deleted
|
||||
* To delete the admin user, first make the Admin user a normal user
|
||||
* (AdminService)
|
||||
*/
|
||||
export const ADMIN_CAN_NOT_BE_DELETED =
|
||||
'admin/admin_can_not_be_deleted' as const;
|
||||
|
||||
/**
|
||||
* Token Authorization failed (Check 'Authorization' Header)
|
||||
* (GqlAuthGuard)
|
||||
*/
|
||||
export const AUTH_FAIL = 'auth/fail';
|
||||
|
||||
/**
|
||||
* Invalid JSON
|
||||
* (Utils)
|
||||
*/
|
||||
export const JSON_INVALID = 'json_invalid';
|
||||
|
||||
/**
|
||||
* Auth Provider not specified
|
||||
* (Auth)
|
||||
*/
|
||||
export const AUTH_PROVIDER_NOT_SPECIFIED = 'auth/provider_not_specified';
|
||||
|
||||
/**
|
||||
* Auth Provider not specified
|
||||
* (Auth)
|
||||
*/
|
||||
export const AUTH_PROVIDER_NOT_CONFIGURED =
|
||||
'auth/provider_not_configured_correctly';
|
||||
|
||||
/**
|
||||
* Environment variable "VITE_ALLOWED_AUTH_PROVIDERS" is not present in .env file
|
||||
*/
|
||||
export const ENV_NOT_FOUND_KEY_AUTH_PROVIDERS =
|
||||
'"VITE_ALLOWED_AUTH_PROVIDERS" is not present in .env file';
|
||||
|
||||
/**
|
||||
* Environment variable "VITE_ALLOWED_AUTH_PROVIDERS" is empty in .env file
|
||||
*/
|
||||
export const ENV_EMPTY_AUTH_PROVIDERS =
|
||||
'"VITE_ALLOWED_AUTH_PROVIDERS" is empty in .env file';
|
||||
|
||||
/**
|
||||
* Environment variable "VITE_ALLOWED_AUTH_PROVIDERS" contains unsupported provider in .env file
|
||||
*/
|
||||
export const ENV_NOT_SUPPORT_AUTH_PROVIDERS =
|
||||
'"VITE_ALLOWED_AUTH_PROVIDERS" contains an unsupported auth provider in .env file';
|
||||
|
||||
/**
|
||||
* Tried to delete a user data document from fb firestore but failed.
|
||||
* (FirebaseService)
|
||||
*/
|
||||
export const USER_FB_DOCUMENT_DELETION_FAILED =
|
||||
'fb/firebase_document_deletion_failed' as const;
|
||||
|
||||
/**
|
||||
* Tried to do an action on a user where user is not found
|
||||
*/
|
||||
export const USER_NOT_FOUND = 'user/not_found' as const;
|
||||
|
||||
/**
|
||||
* User is already invited by admin
|
||||
*/
|
||||
export const USER_ALREADY_INVITED = 'admin/user_already_invited' as const;
|
||||
|
||||
/**
|
||||
* User update failure
|
||||
* (UserService)
|
||||
*/
|
||||
export const USER_UPDATE_FAILED = 'user/update_failed' as const;
|
||||
|
||||
/**
|
||||
* User display name validation failure
|
||||
* (UserService)
|
||||
*/
|
||||
export const USER_SHORT_DISPLAY_NAME = 'user/short_display_name' as const;
|
||||
|
||||
/**
|
||||
* User deletion failure
|
||||
* (UserService)
|
||||
*/
|
||||
export const USER_DELETION_FAILED = 'user/deletion_failed' as const;
|
||||
|
||||
/**
|
||||
* Users not found
|
||||
* (UserService)
|
||||
*/
|
||||
export const USERS_NOT_FOUND = 'user/users_not_found' as const;
|
||||
|
||||
/**
|
||||
* User deletion failure error due to user being a team owner
|
||||
* (UserService)
|
||||
*/
|
||||
export const USER_IS_OWNER = 'user/is_owner' as const;
|
||||
/**
|
||||
* User deletion failure error due to user being an admin
|
||||
* (UserService)
|
||||
*/
|
||||
export const USER_IS_ADMIN = 'user/is_admin' as const;
|
||||
|
||||
/**
|
||||
* User invite deletion failure error due to invitation not found
|
||||
* (AdminService)
|
||||
*/
|
||||
export const USER_INVITATION_DELETION_FAILED =
|
||||
'user/invitation_deletion_failed' as const;
|
||||
|
||||
/**
|
||||
* Teams not found
|
||||
* (TeamsService)
|
||||
*/
|
||||
export const TEAMS_NOT_FOUND = 'user/teams_not_found' as const;
|
||||
|
||||
/**
|
||||
* Tried to find user collection but failed
|
||||
* (UserRequestService)
|
||||
*/
|
||||
export const USER_COLLECTION_NOT_FOUND = 'user_collection/not_found' as const;
|
||||
|
||||
/**
|
||||
* Tried to reorder user request but failed
|
||||
* (UserRequestService)
|
||||
*/
|
||||
export const USER_REQUEST_CREATION_FAILED =
|
||||
'user_request/creation_failed' as const;
|
||||
|
||||
/**
|
||||
* Tried to do an action on a user request but user request is not matched with user collection
|
||||
* (UserRequestService)
|
||||
*/
|
||||
export const USER_REQUEST_INVALID_TYPE = 'user_request/type_mismatch' as const;
|
||||
|
||||
/**
|
||||
* Tried to do an action on a user request where user request is not found
|
||||
* (UserRequestService)
|
||||
*/
|
||||
export const USER_REQUEST_NOT_FOUND = 'user_request/not_found' as const;
|
||||
|
||||
/**
|
||||
* Tried to reorder user request but failed
|
||||
* (UserRequestService)
|
||||
*/
|
||||
export const USER_REQUEST_REORDERING_FAILED =
|
||||
'user_request/reordering_failed' as const;
|
||||
|
||||
/**
|
||||
* Tried to perform action on a team which they are not a member of
|
||||
* (GqlTeamMemberGuard)
|
||||
*/
|
||||
export const TEAM_MEMBER_NOT_FOUND = 'team/member_not_found' as const;
|
||||
|
||||
/**
|
||||
* Tried to perform action on a team that doesn't accept their member role level
|
||||
* (GqlTeamMemberGuard)
|
||||
*/
|
||||
export const TEAM_NOT_REQUIRED_ROLE = 'team/not_required_role' as const;
|
||||
|
||||
/**
|
||||
* Team name validation failure
|
||||
* (TeamService)
|
||||
*/
|
||||
export const TEAM_NAME_INVALID = 'team/name_invalid';
|
||||
|
||||
/**
|
||||
* Couldn't find the sync data from the user
|
||||
* (TeamCollectionService)
|
||||
*/
|
||||
export const TEAM_USER_NO_FB_SYNCDATA = 'team/user_no_fb_syncdata';
|
||||
|
||||
/**
|
||||
* There was a problem resolving the firebase collection path
|
||||
* (TeamCollectionService)
|
||||
*/
|
||||
export const TEAM_FB_COLL_PATH_RESOLVE_FAIL = 'team/fb_coll_path_resolve_fail';
|
||||
|
||||
/**
|
||||
* Could not find the team in the database
|
||||
* (TeamCollectionService)
|
||||
*/
|
||||
export const TEAM_COLL_NOT_FOUND = 'team_coll/collection_not_found';
|
||||
|
||||
/**
|
||||
* Cannot make parent collection a child of a collection that a child of itself
|
||||
* (TeamCollectionService)
|
||||
*/
|
||||
export const TEAM_COLL_IS_PARENT_COLL = 'team_coll/collection_is_parent_coll';
|
||||
|
||||
/**
|
||||
* Target and Parent collections are not from the same team
|
||||
* (TeamCollectionService)
|
||||
*/
|
||||
export const TEAM_COLL_NOT_SAME_TEAM = 'team_coll/collections_not_same_team';
|
||||
|
||||
/**
|
||||
* Target and Parent collections are the same
|
||||
* (TeamCollectionService)
|
||||
*/
|
||||
export const TEAM_COLL_DEST_SAME =
|
||||
'team_coll/target_and_destination_collection_are_same';
|
||||
|
||||
/**
|
||||
* Collection is already a root collection
|
||||
* (TeamCollectionService)
|
||||
*/
|
||||
export const TEAM_COL_ALREADY_ROOT =
|
||||
'team_coll/target_collection_is_already_root_collection';
|
||||
|
||||
/**
|
||||
* Collections have different parents
|
||||
* (TeamCollectionService)
|
||||
*/
|
||||
export const TEAM_COL_NOT_SAME_PARENT =
|
||||
'team_coll/team_collections_have_different_parents';
|
||||
|
||||
/**
|
||||
* Collection and next Collection are the same
|
||||
* (TeamCollectionService)
|
||||
*/
|
||||
export const TEAM_COL_SAME_NEXT_COLL =
|
||||
'team_coll/collection_and_next_collection_are_same';
|
||||
|
||||
/**
|
||||
* Team Collection search failed
|
||||
* (TeamCollectionService)
|
||||
*/
|
||||
export const TEAM_COL_SEARCH_FAILED = 'team_coll/team_collection_search_failed';
|
||||
|
||||
/**
|
||||
* Team Collection Re-Ordering Failed
|
||||
* (TeamCollectionService)
|
||||
*/
|
||||
export const TEAM_COL_REORDERING_FAILED = 'team_coll/reordering_failed';
|
||||
|
||||
/**
|
||||
* Tried to update the team to a state it doesn't have any owners
|
||||
* (TeamService)
|
||||
*/
|
||||
export const TEAM_ONLY_ONE_OWNER = 'team/only_one_owner';
|
||||
|
||||
/**
|
||||
* Invalid or non-existent Team ID
|
||||
* (TeamService)
|
||||
*/
|
||||
export const TEAM_INVALID_ID = 'team/invalid_id' as const;
|
||||
|
||||
/**
|
||||
* Invalid or non-existent collection id
|
||||
* (GqlCollectionTeamMemberGuard)
|
||||
*/
|
||||
export const TEAM_INVALID_COLL_ID = 'team/invalid_coll_id' as const;
|
||||
|
||||
/**
|
||||
* Invalid team id or user id
|
||||
* (TeamService)
|
||||
*/
|
||||
export const TEAM_INVALID_ID_OR_USER = 'team/invalid_id_or_user';
|
||||
|
||||
/**
|
||||
* The provided title for the team collection is short (less than 3 characters)
|
||||
* (TeamCollectionService)
|
||||
*/
|
||||
export const TEAM_COLL_SHORT_TITLE = 'team_coll/short_title';
|
||||
|
||||
/**
|
||||
* The JSON used is not valid
|
||||
* (TeamCollectionService)
|
||||
*/
|
||||
export const TEAM_COLL_INVALID_JSON = 'team_coll/invalid_json';
|
||||
|
||||
/**
|
||||
* The Team Collection does not belong to the team
|
||||
* (TeamCollectionService)
|
||||
*/
|
||||
export const TEAM_NOT_OWNER = 'team_coll/team_not_owner' as const;
|
||||
|
||||
/**
|
||||
* The Team Collection data is not valid
|
||||
* (TeamCollectionService)
|
||||
*/
|
||||
export const TEAM_COLL_DATA_INVALID =
|
||||
'team_coll/team_coll_data_invalid' as const;
|
||||
|
||||
/**
|
||||
* Team Collection parent tree generation failed
|
||||
* (TeamCollectionService)
|
||||
*/
|
||||
export const TEAM_COLL_PARENT_TREE_GEN_FAILED =
|
||||
'team_coll/team_coll_parent_tree_generation_failed';
|
||||
|
||||
/**
|
||||
* Tried to perform an action on a request that doesn't accept their member role level
|
||||
* (GqlRequestTeamMemberGuard)
|
||||
*/
|
||||
export const TEAM_REQ_NOT_REQUIRED_ROLE = 'team_req/not_required_role';
|
||||
|
||||
/**
|
||||
* Tried to operate on a request which does not exist
|
||||
* (TeamRequestService)
|
||||
*/
|
||||
export const TEAM_REQ_NOT_FOUND = 'team_req/not_found' as const;
|
||||
|
||||
/**
|
||||
* Invalid or non-existent collection id
|
||||
* (TeamRequestService)
|
||||
*/
|
||||
export const TEAM_REQ_INVALID_TARGET_COLL_ID =
|
||||
'team_req/invalid_target_id' as const;
|
||||
|
||||
/**
|
||||
* Tried to reorder team request but failed
|
||||
* (TeamRequestService)
|
||||
*/
|
||||
export const TEAM_REQ_REORDERING_FAILED = 'team_req/reordering_failed' as const;
|
||||
|
||||
/**
|
||||
* Team Request search failed
|
||||
* (TeamRequestService)
|
||||
*/
|
||||
export const TEAM_REQ_SEARCH_FAILED = 'team_req/team_request_search_failed';
|
||||
|
||||
/**
|
||||
* Team Request parent tree generation failed
|
||||
* (TeamRequestService)
|
||||
*/
|
||||
export const TEAM_REQ_PARENT_TREE_GEN_FAILED =
|
||||
'team_req/team_req_parent_tree_generation_failed';
|
||||
|
||||
/**
|
||||
* No Postmark Sender Email defined
|
||||
* (AuthService)
|
||||
*/
|
||||
export const SENDER_EMAIL_INVALID = 'mailer/sender_email_invalid' as const;
|
||||
|
||||
/**
|
||||
* Tried to perform an action on a request when the user is not even a member of the team
|
||||
* (GqlRequestTeamMemberGuard, GqlCollectionTeamMemberGuard)
|
||||
*/
|
||||
export const TEAM_REQ_NOT_MEMBER = 'team_req/not_member';
|
||||
|
||||
export const TEAM_INVITE_MEMBER_HAS_INVITE =
|
||||
'team_invite/member_has_invite' as const;
|
||||
|
||||
export const TEAM_INVITE_NO_INVITE_FOUND =
|
||||
'team_invite/no_invite_found' as const;
|
||||
|
||||
export const TEAM_INVITE_ALREADY_MEMBER = 'team_invite/already_member' as const;
|
||||
|
||||
export const TEAM_INVITE_EMAIL_DO_NOT_MATCH =
|
||||
'team_invite/email_do_not_match' as const;
|
||||
|
||||
export const TEAM_INVITE_NOT_VALID_VIEWER =
|
||||
'team_invite/not_valid_viewer' as const;
|
||||
|
||||
/**
|
||||
* No team invitations found
|
||||
* (TeamInvitationService)
|
||||
*/
|
||||
export const TEAM_INVITATION_NOT_FOUND =
|
||||
'team_invite/invitations_not_found' as const;
|
||||
|
||||
/**
|
||||
* ShortCode not found in DB
|
||||
* (ShortcodeService)
|
||||
*/
|
||||
export const SHORTCODE_NOT_FOUND = 'shortcode/not_found' as const;
|
||||
|
||||
/**
|
||||
* Invalid or non-existent TEAM ENVIRONMENT ID
|
||||
* (TeamEnvironmentsService)
|
||||
*/
|
||||
export const TEAM_ENVIRONMENT_NOT_FOUND = 'team_environment/not_found' as const;
|
||||
|
||||
/**
|
||||
* Invalid TEAM ENVIRONMENT name
|
||||
* (TeamEnvironmentsService)
|
||||
*/
|
||||
export const TEAM_ENVIRONMENT_SHORT_NAME =
|
||||
'team_environment/short_name' as const;
|
||||
|
||||
/**
|
||||
* The user is not a member of the team of the given environment
|
||||
* (GqlTeamEnvTeamGuard)
|
||||
*/
|
||||
export const TEAM_ENVIRONMENT_NOT_TEAM_MEMBER =
|
||||
'team_environment/not_team_member' as const;
|
||||
|
||||
/**
|
||||
* User setting not found for a user
|
||||
* (UserSettingsService)
|
||||
*/
|
||||
export const USER_SETTINGS_NOT_FOUND = 'user_settings/not_found' as const;
|
||||
|
||||
/**
|
||||
* User setting already exists for a user
|
||||
* (UserSettingsService)
|
||||
*/
|
||||
export const USER_SETTINGS_ALREADY_EXISTS =
|
||||
'user_settings/settings_already_exists' as const;
|
||||
|
||||
/**
|
||||
* User setting invalid (null) settings
|
||||
* (UserSettingsService)
|
||||
*/
|
||||
export const USER_SETTINGS_NULL_SETTINGS =
|
||||
'user_settings/null_settings' as const;
|
||||
|
||||
/*
|
||||
* Global environment doesn't exist for the user
|
||||
* (UserEnvironmentsService)
|
||||
*/
|
||||
export const USER_ENVIRONMENT_GLOBAL_ENV_DOES_NOT_EXISTS =
|
||||
'user_environment/global_env_does_not_exists' as const;
|
||||
|
||||
/**
|
||||
* Global environment already exists for the user
|
||||
* (UserEnvironmentsService)
|
||||
*/
|
||||
export const USER_ENVIRONMENT_GLOBAL_ENV_EXISTS =
|
||||
'user_environment/global_env_already_exists' as const;
|
||||
/*
|
||||
|
||||
/**
|
||||
* User environment doesn't exist for the user
|
||||
* (UserEnvironmentsService)
|
||||
*/
|
||||
export const USER_ENVIRONMENT_ENV_DOES_NOT_EXISTS =
|
||||
'user_environment/user_env_does_not_exists' as const;
|
||||
/*
|
||||
|
||||
/**
|
||||
* Cannot delete the global user environment
|
||||
* (UserEnvironmentsService)
|
||||
*/
|
||||
export const USER_ENVIRONMENT_GLOBAL_ENV_DELETION_FAILED =
|
||||
'user_environment/user_env_global_env_deletion_failed' as const;
|
||||
/*
|
||||
|
||||
/**
|
||||
* User environment is not a global environment
|
||||
* (UserEnvironmentsService)
|
||||
*/
|
||||
export const USER_ENVIRONMENT_IS_NOT_GLOBAL =
|
||||
'user_environment/user_env_is_not_global' as const;
|
||||
/*
|
||||
|
||||
/**
|
||||
* User environment update failed
|
||||
* (UserEnvironmentsService)
|
||||
*/
|
||||
export const USER_ENVIRONMENT_UPDATE_FAILED =
|
||||
'user_environment/user_env_update_failed' as const;
|
||||
/*
|
||||
|
||||
/**
|
||||
* User environment invalid environment name
|
||||
* (UserEnvironmentsService)
|
||||
*/
|
||||
export const USER_ENVIRONMENT_INVALID_ENVIRONMENT_NAME =
|
||||
'user_environment/user_env_invalid_env_name' as const;
|
||||
/*
|
||||
|
||||
/**
|
||||
* User history not found
|
||||
* (UserHistoryService)
|
||||
*/
|
||||
export const USER_HISTORY_NOT_FOUND = 'user_history/history_not_found' as const;
|
||||
|
||||
/*
|
||||
|
||||
/**
|
||||
* Invalid Request Type in History
|
||||
* (UserHistoryService)
|
||||
*/
|
||||
export const USER_HISTORY_INVALID_REQ_TYPE =
|
||||
'user_history/req_type_invalid' as const;
|
||||
|
||||
/*
|
||||
|
||||
|------------------------------------|
|
||||
|Server errors that are actually bugs|
|
||||
|------------------------------------|
|
||||
|
||||
*/
|
||||
|
||||
/**
|
||||
* Couldn't find user data from the GraphQL context (Check if GqlAuthGuard is applied)
|
||||
* (GqlTeamMemberGuard, GqlCollectionTeamMemberGuard)
|
||||
*/
|
||||
export const BUG_AUTH_NO_USER_CTX = 'bug/auth/auth_no_user_ctx' as const;
|
||||
|
||||
/**
|
||||
* Couldn't find teamID parameter in the attached GraphQL operation. (Check if teamID is present)
|
||||
* (GqlTeamMemberGuard, GQLEAAdminGuard, GqlCollectionTeamMemberGuard)
|
||||
*/
|
||||
export const BUG_TEAM_NO_TEAM_ID = 'bug/team/no_team_id';
|
||||
|
||||
/**
|
||||
* Couldn't find RequireTeamRole decorator. (Check if it is applied)
|
||||
* (GqlTeamMemberGuard)
|
||||
*/
|
||||
export const BUG_TEAM_NO_REQUIRE_TEAM_ROLE = 'bug/team/no_require_team_role';
|
||||
|
||||
/**
|
||||
* Couldn't find 'collectionID' param to the attached GQL operation. (Check if exists)
|
||||
* (GqlCollectionTeamMemberGuard)
|
||||
*/
|
||||
export const BUG_TEAM_COLL_NO_COLL_ID = 'bug/team_coll/no_coll_id';
|
||||
|
||||
/**
|
||||
* Couldn't find 'requestID' param to the attached GQL operation. (Check if exists)
|
||||
* (GqlRequestTeamMemberGuard)
|
||||
*/
|
||||
export const BUG_TEAM_REQ_NO_REQ_ID = 'bug/team_req/no_req_id';
|
||||
|
||||
export const BUG_TEAM_INVITE_NO_INVITE_ID =
|
||||
'bug/team_invite/no_invite_id' as const;
|
||||
|
||||
/**
|
||||
* Couldn't find RequireTeamRole decorator. (Check if it is applied)
|
||||
* (GqlTeamEnvTeamGuard)
|
||||
*/
|
||||
export const BUG_TEAM_ENV_GUARD_NO_REQUIRE_ROLES =
|
||||
'bug/team_env/guard_no_require_roles' as const;
|
||||
|
||||
/**
|
||||
* Couldn't find 'id' param to the operation. (Check if it is applied)
|
||||
* (GqlTeamEnvTeamGuard)
|
||||
*/
|
||||
export const BUG_TEAM_ENV_GUARD_NO_ENV_ID =
|
||||
'bug/team_env/guard_no_env_id' as const;
|
||||
|
||||
/**
|
||||
* The data sent to the verify route are invalid
|
||||
* (AuthService)
|
||||
*/
|
||||
export const INVALID_MAGIC_LINK_DATA = 'auth/magic_link_invalid_data' as const;
|
||||
|
||||
/**
|
||||
* Could not find VerificationToken entry in the db
|
||||
* (AuthService)
|
||||
*/
|
||||
export const VERIFICATION_TOKEN_DATA_NOT_FOUND =
|
||||
'auth/verification_token_data_not_found' as const;
|
||||
|
||||
/**
|
||||
* Auth Tokens expired
|
||||
* (AuthService)
|
||||
*/
|
||||
export const TOKEN_EXPIRED = 'auth/token_expired' as const;
|
||||
|
||||
/**
|
||||
* VerificationToken Tokens expired i.e. magic-link expired
|
||||
* (AuthService)
|
||||
*/
|
||||
export const MAGIC_LINK_EXPIRED = 'auth/magic_link_expired' as const;
|
||||
|
||||
/**
|
||||
* No cookies were found in the auth request
|
||||
* (AuthService)
|
||||
*/
|
||||
export const COOKIES_NOT_FOUND = 'auth/cookies_not_found' as const;
|
||||
|
||||
/**
|
||||
* Access Token is malformed or invalid
|
||||
* (AuthService)
|
||||
*/
|
||||
export const INVALID_ACCESS_TOKEN = 'auth/invalid_access_token' as const;
|
||||
|
||||
/**
|
||||
* Refresh Token is malformed or invalid
|
||||
* (AuthService)
|
||||
*/
|
||||
export const INVALID_REFRESH_TOKEN = 'auth/invalid_refresh_token' as const;
|
||||
|
||||
/**
|
||||
* The provided title for the user collection is short (less than 3 characters)
|
||||
* (UserCollectionService)
|
||||
*/
|
||||
export const USER_COLL_SHORT_TITLE = 'user_coll/short_title' as const;
|
||||
|
||||
/**
|
||||
* User Collection could not be found
|
||||
* (UserCollectionService)
|
||||
*/
|
||||
export const USER_COLL_NOT_FOUND = 'user_coll/not_found' as const;
|
||||
|
||||
/**
|
||||
* UserCollection is already a root collection
|
||||
* (UserCollectionService)
|
||||
*/
|
||||
export const USER_COLL_ALREADY_ROOT =
|
||||
'user_coll/target_user_collection_is_already_root_user_collection' as const;
|
||||
|
||||
/**
|
||||
* Target and Parent user collections are the same
|
||||
* (UserCollectionService)
|
||||
*/
|
||||
export const USER_COLL_DEST_SAME =
|
||||
'user_coll/target_and_destination_user_collection_are_same' as const;
|
||||
|
||||
/**
|
||||
* Target and Parent user collections are not from the same user
|
||||
* (UserCollectionService)
|
||||
*/
|
||||
export const USER_COLL_NOT_SAME_USER = 'user_coll/not_same_user' as const;
|
||||
|
||||
/**
|
||||
* Target and Parent user collections are not from the same type
|
||||
* (UserCollectionService)
|
||||
*/
|
||||
export const USER_COLL_NOT_SAME_TYPE = 'user_coll/type_mismatch' as const;
|
||||
|
||||
/**
|
||||
* Cannot make a parent user collection a child of itself
|
||||
* (UserCollectionService)
|
||||
*/
|
||||
export const USER_COLL_IS_PARENT_COLL =
|
||||
'user_coll/user_collection_is_parent_coll' as const;
|
||||
|
||||
/**
|
||||
* User Collection Re-Ordering Failed
|
||||
* (UserCollectionService)
|
||||
*/
|
||||
export const USER_COLL_REORDERING_FAILED =
|
||||
'user_coll/reordering_failed' as const;
|
||||
|
||||
/**
|
||||
* The Collection and Next User Collection are the same
|
||||
* (UserCollectionService)
|
||||
*/
|
||||
export const USER_COLL_SAME_NEXT_COLL =
|
||||
'user_coll/user_collection_and_next_user_collection_are_same' as const;
|
||||
|
||||
/**
|
||||
* The User Collection data is not valid
|
||||
* (UserCollectionService)
|
||||
*/
|
||||
export const USER_COLL_DATA_INVALID =
|
||||
'user_coll/user_coll_data_invalid' as const;
|
||||
|
||||
/**
|
||||
* The User Collection does not belong to the logged-in user
|
||||
* (UserCollectionService)
|
||||
*/
|
||||
export const USER_NOT_OWNER = 'user_coll/user_not_owner' as const;
|
||||
|
||||
/**
|
||||
* The JSON used is not valid
|
||||
* (UserCollectionService)
|
||||
*/
|
||||
export const USER_COLL_INVALID_JSON = 'user_coll/invalid_json';
|
||||
|
||||
/*
|
||||
* MAILER_SMTP_URL environment variable is not defined
|
||||
* (MailerModule)
|
||||
*/
|
||||
export const MAILER_SMTP_URL_UNDEFINED = 'mailer/smtp_url_undefined' as const;
|
||||
|
||||
/**
|
||||
* MAILER_ADDRESS_FROM environment variable is not defined
|
||||
* (MailerModule)
|
||||
*/
|
||||
export const MAILER_FROM_ADDRESS_UNDEFINED =
|
||||
'mailer/from_address_undefined' as const;
|
||||
|
||||
/**
|
||||
* MAILER_SMTP_USER environment variable is not defined
|
||||
* (MailerModule)
|
||||
*/
|
||||
export const MAILER_SMTP_USER_UNDEFINED = 'mailer/smtp_user_undefined' as const;
|
||||
|
||||
/**
|
||||
* MAILER_SMTP_PASSWORD environment variable is not defined
|
||||
* (MailerModule)
|
||||
*/
|
||||
export const MAILER_SMTP_PASSWORD_UNDEFINED =
|
||||
'mailer/smtp_password_undefined' as const;
|
||||
|
||||
/**
|
||||
* SharedRequest invalid request JSON format
|
||||
* (ShortcodeService)
|
||||
*/
|
||||
export const SHORTCODE_INVALID_REQUEST_JSON =
|
||||
'shortcode/request_invalid_format' as const;
|
||||
|
||||
/**
|
||||
* SharedRequest invalid properties JSON format
|
||||
* (ShortcodeService)
|
||||
*/
|
||||
export const SHORTCODE_INVALID_PROPERTIES_JSON =
|
||||
'shortcode/properties_invalid_format' as const;
|
||||
|
||||
/**
|
||||
* SharedRequest invalid properties not found
|
||||
* (ShortcodeService)
|
||||
*/
|
||||
export const SHORTCODE_PROPERTIES_NOT_FOUND =
|
||||
'shortcode/properties_not_found' as const;
|
||||
|
||||
/**
|
||||
* Infra Config not found
|
||||
* (InfraConfigService)
|
||||
*/
|
||||
export const INFRA_CONFIG_NOT_FOUND = 'infra_config/not_found' as const;
|
||||
|
||||
/**
|
||||
* Infra Config update failed
|
||||
* (InfraConfigService)
|
||||
*/
|
||||
export const INFRA_CONFIG_UPDATE_FAILED = 'infra_config/update_failed' as const;
|
||||
|
||||
/**
|
||||
* Infra Config not listed for onModuleInit creation
|
||||
* (InfraConfigService)
|
||||
*/
|
||||
export const INFRA_CONFIG_NOT_LISTED =
|
||||
'infra_config/properly_not_listed' as const;
|
||||
|
||||
/**
|
||||
* Infra Config reset failed
|
||||
* (InfraConfigService)
|
||||
*/
|
||||
export const INFRA_CONFIG_RESET_FAILED = 'infra_config/reset_failed' as const;
|
||||
|
||||
/**
|
||||
* Infra Config invalid input for Config variable
|
||||
* (InfraConfigService)
|
||||
*/
|
||||
export const INFRA_CONFIG_INVALID_INPUT = 'infra_config/invalid_input' as const;
|
||||
|
||||
/**
|
||||
* Infra Config service (auth provider/mailer/audit logs) not configured
|
||||
* (InfraConfigService)
|
||||
*/
|
||||
export const INFRA_CONFIG_SERVICE_NOT_CONFIGURED =
|
||||
'infra_config/service_not_configured' as const;
|
||||
|
||||
/**
|
||||
* Infra Config update/fetch operation not allowed
|
||||
* (InfraConfigService)
|
||||
*/
|
||||
export const INFRA_CONFIG_OPERATION_NOT_ALLOWED =
|
||||
'infra_config/operation_not_allowed';
|
||||
|
||||
/**
|
||||
* Error message for when the database table does not exist
|
||||
* (InfraConfigService)
|
||||
*/
|
||||
export const DATABASE_TABLE_NOT_EXIST =
|
||||
'Database migration not found. Please check the documentation for assistance: https://docs.hoppscotch.io/documentation/self-host/community-edition/install-and-build#running-migrations';
|
||||
|
||||
/**
|
||||
* PostHog client is not initialized
|
||||
* (InfraConfigService)
|
||||
*/
|
||||
export const POSTHOG_CLIENT_NOT_INITIALIZED = 'posthog/client_not_initialized';
|
||||
|
||||
/**
|
||||
* Inputs supplied are invalid
|
||||
*/
|
||||
export const INVALID_PARAMS = 'invalid_parameters' as const;
|
||||
|
||||
/**
|
||||
* The provided label for the access-token is short (less than 3 characters)
|
||||
* (AccessTokenService)
|
||||
*/
|
||||
export const ACCESS_TOKEN_LABEL_SHORT = 'access_token/label_too_short';
|
||||
|
||||
/**
|
||||
* The provided expiryInDays value is not valid
|
||||
* (AccessTokenService)
|
||||
*/
|
||||
export const ACCESS_TOKEN_EXPIRY_INVALID = 'access_token/expiry_days_invalid';
|
||||
|
||||
/**
|
||||
* The provided PAT ID is invalid
|
||||
* (AccessTokenService)
|
||||
*/
|
||||
export const ACCESS_TOKEN_NOT_FOUND = 'access_token/access_token_not_found';
|
||||
|
||||
/**
|
||||
* AccessTokens is expired
|
||||
* (AccessTokenService)
|
||||
*/
|
||||
export const ACCESS_TOKEN_EXPIRED = 'TOKEN_EXPIRED';
|
||||
|
||||
/**
|
||||
* AccessTokens is invalid
|
||||
* (AccessTokenService)
|
||||
*/
|
||||
export const ACCESS_TOKEN_INVALID = 'TOKEN_INVALID';
|
||||
|
||||
/**
|
||||
* AccessTokens is invalid
|
||||
* (AccessTokenService)
|
||||
*/
|
||||
export const ACCESS_TOKENS_INVALID_DATA_ID = 'INVALID_ID';
|
||||
|
||||
/**
|
||||
* The provided label for the infra-token is short (less than 3 characters)
|
||||
* (InfraTokenService)
|
||||
*/
|
||||
export const INFRA_TOKEN_LABEL_SHORT = 'infra_token/label_too_short';
|
||||
|
||||
/**
|
||||
* The provided expiryInDays value is not valid
|
||||
* (InfraTokenService)
|
||||
*/
|
||||
export const INFRA_TOKEN_EXPIRY_INVALID = 'infra_token/expiry_days_invalid';
|
||||
|
||||
/**
|
||||
* The provided Infra Token ID is invalid
|
||||
* (InfraTokenService)
|
||||
*/
|
||||
export const INFRA_TOKEN_NOT_FOUND = 'infra_token/infra_token_not_found';
|
||||
|
||||
/**
|
||||
* Authorization missing in header (Check 'Authorization' Header)
|
||||
* (InfraTokenGuard)
|
||||
*/
|
||||
export const INFRA_TOKEN_HEADER_MISSING =
|
||||
'infra_token/authorization_token_missing';
|
||||
|
||||
/**
|
||||
* Infra Token is invalid
|
||||
* (InfraTokenGuard)
|
||||
*/
|
||||
export const INFRA_TOKEN_INVALID_TOKEN = 'infra_token/invalid_token';
|
||||
|
||||
/**
|
||||
* Infra Token is expired
|
||||
* (InfraTokenGuard)
|
||||
*/
|
||||
export const INFRA_TOKEN_EXPIRED = 'infra_token/expired';
|
||||
|
||||
/**
|
||||
* Token creator not found
|
||||
* (InfraTokenService)
|
||||
*/
|
||||
export const INFRA_TOKEN_CREATOR_NOT_FOUND = 'infra_token/creator_not_found';
|
||||
116
packages/hoppscotch-backend/src/gql-schema.ts
Normal file
116
packages/hoppscotch-backend/src/gql-schema.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import {
|
||||
GraphQLSchemaBuilderModule,
|
||||
GraphQLSchemaFactory,
|
||||
} from '@nestjs/graphql';
|
||||
import { printSchema } from 'graphql/utilities';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { ShortcodeResolver } from './shortcode/shortcode.resolver';
|
||||
import { TeamCollectionResolver } from './team-collection/team-collection.resolver';
|
||||
import { TeamEnvironmentsResolver } from './team-environments/team-environments.resolver';
|
||||
import { TeamInvitationResolver } from './team-invitation/team-invitation.resolver';
|
||||
import { TeamRequestResolver } from './team-request/team-request.resolver';
|
||||
import { TeamMemberResolver } from './team/team-member.resolver';
|
||||
import { TeamResolver } from './team/team.resolver';
|
||||
import { UserCollectionResolver } from './user-collection/user-collection.resolver';
|
||||
import { UserEnvironmentsResolver } from './user-environment/user-environments.resolver';
|
||||
import { UserHistoryResolver } from './user-history/user-history.resolver';
|
||||
import { UserRequestResolver } from './user-request/resolvers/user-request.resolver';
|
||||
import { UserSettingsResolver } from './user-settings/user-settings.resolver';
|
||||
import { UserResolver } from './user/user.resolver';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { AdminResolver } from './admin/admin.resolver';
|
||||
import { TeamEnvsTeamResolver } from './team-environments/team.resolver';
|
||||
import { TeamTeamInviteExtResolver } from './team-invitation/team-teaminvite-ext.resolver';
|
||||
import { UserRequestUserCollectionResolver } from './user-request/resolvers/user-collection.resolver';
|
||||
import { UserEnvsUserResolver } from './user-environment/user.resolver';
|
||||
import { UserHistoryUserResolver } from './user-history/user.resolver';
|
||||
import { UserSettingsUserResolver } from './user-settings/user.resolver';
|
||||
import { InfraResolver } from './admin/infra.resolver';
|
||||
import { InfraConfigResolver } from './infra-config/infra-config.resolver';
|
||||
import { InfraTokenResolver } from './infra-token/infra-token.resolver';
|
||||
|
||||
/**
|
||||
* All the resolvers present in the application.
|
||||
*
|
||||
* NOTE: This needs to be KEPT UP-TO-DATE to keep the schema accurate
|
||||
*/
|
||||
const RESOLVERS = [
|
||||
InfraResolver,
|
||||
AdminResolver,
|
||||
ShortcodeResolver,
|
||||
TeamResolver,
|
||||
TeamEnvsTeamResolver,
|
||||
TeamMemberResolver,
|
||||
TeamCollectionResolver,
|
||||
TeamTeamInviteExtResolver,
|
||||
TeamEnvironmentsResolver,
|
||||
TeamEnvsTeamResolver,
|
||||
TeamInvitationResolver,
|
||||
TeamRequestResolver,
|
||||
UserResolver,
|
||||
UserCollectionResolver,
|
||||
UserEnvironmentsResolver,
|
||||
UserEnvsUserResolver,
|
||||
UserHistoryUserResolver,
|
||||
UserHistoryResolver,
|
||||
UserCollectionResolver,
|
||||
UserRequestResolver,
|
||||
UserRequestUserCollectionResolver,
|
||||
UserSettingsResolver,
|
||||
UserSettingsUserResolver,
|
||||
InfraConfigResolver,
|
||||
InfraTokenResolver,
|
||||
];
|
||||
|
||||
/**
|
||||
* All the custom scalars present in the application.
|
||||
*
|
||||
* NOTE: This needs to be KEPT UP-TO-DATE to keep the schema accurate
|
||||
*/
|
||||
const SCALARS = [];
|
||||
|
||||
/**
|
||||
* Generates the GraphQL Schema SDL definition and writes it into the location
|
||||
* specified by the `GQL_SCHEMA_EMIT_LOCATION` environment variable.
|
||||
*/
|
||||
export async function emitGQLSchemaFile() {
|
||||
const logger = new Logger('emitGQLSchemaFile');
|
||||
|
||||
try {
|
||||
const destination = path.resolve(
|
||||
__dirname,
|
||||
process.env.GQL_SCHEMA_EMIT_LOCATION ?? '../gen/schema.gql',
|
||||
);
|
||||
|
||||
logger.log(`GQL_SCHEMA_EMIT_LOCATION: ${destination}`);
|
||||
|
||||
const app = await NestFactory.create(GraphQLSchemaBuilderModule);
|
||||
await app.init();
|
||||
|
||||
const gqlSchemaFactory = app.get(GraphQLSchemaFactory);
|
||||
|
||||
logger.log(
|
||||
`Generating Schema against ${RESOLVERS.length} resolvers and ${SCALARS.length} custom scalars`,
|
||||
);
|
||||
|
||||
const schema = await gqlSchemaFactory.create(RESOLVERS, SCALARS, {
|
||||
numberScalarMode: 'integer',
|
||||
});
|
||||
|
||||
const schemaString = printSchema(schema);
|
||||
|
||||
logger.log(`Writing schema to GQL_SCHEMA_EMIT_LOCATION (${destination})`);
|
||||
|
||||
// Generating folders if required to emit to the given output
|
||||
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
||||
fs.writeFileSync(destination, schemaString);
|
||||
|
||||
logger.log(`Wrote schema to GQL_SCHEMA_EMIT_LOCATION (${destination})`);
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Failed writing schema to GQL_SCHEMA_EMIT_LOCATION. Reason: ${e}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user