mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Compare commits
799
Commits
1.2.0-rc.1
..
1.20.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7d10efefe | ||
|
|
408d6d76ef | ||
|
|
bf0ed69b60 | ||
|
|
afd9a8eff2 | ||
|
|
baab060628 | ||
|
|
3018515abf | ||
|
|
402d3f3d9d | ||
|
|
e193a300b1 | ||
|
|
0ed3be16f5 | ||
|
|
c34d8c5532 | ||
|
|
aaa0c1d9a3 | ||
|
|
1dfd0c2e56 | ||
|
|
63a94143e2 | ||
|
|
243d724fb2 | ||
|
|
d6235adccd | ||
|
|
ae0f9a770d | ||
|
|
305d55d972 | ||
|
|
aa14367f65 | ||
|
|
8f797df033 | ||
|
|
d680eeb40e | ||
|
|
6ede27667f | ||
|
|
187d100230 | ||
|
|
4f8df0cef6 | ||
|
|
76a7c2ce43 | ||
|
|
4911e49c6b | ||
|
|
f2f5f767b4 | ||
|
|
f209989f11 | ||
|
|
d651568921 | ||
|
|
27348a3341 | ||
|
|
ceba848bdd | ||
|
|
1a36b8b6ef | ||
|
|
61bc4a286d | ||
|
|
f5b3ad7381 | ||
|
|
a96c739d90 | ||
|
|
aaeaeabc81 | ||
|
|
0cf7c45488 | ||
|
|
db6eb221d8 | ||
|
|
130c23dd5b | ||
|
|
6075872881 | ||
|
|
7b65d5648f | ||
|
|
eb4a31e5f4 | ||
|
|
0d2f2957b3 | ||
|
|
51bf9cf679 | ||
|
|
777a8bba3c | ||
|
|
09eba5c0e8 | ||
|
|
b9924f511f | ||
|
|
32da8b5fd0 | ||
|
|
87f8a50326 | ||
|
|
b70a8c8a16 | ||
|
|
ebdef2d302 | ||
|
|
a6bb50c5e2 | ||
|
|
50eb48fcf9 | ||
|
|
6622565b14 | ||
|
|
f001de1e97 | ||
|
|
6711fab413 | ||
|
|
e2f28a84bc | ||
|
|
d1941c2f3d | ||
|
|
f35e6abb05 | ||
|
|
0598f78a34 | ||
|
|
f30b55ba47 | ||
|
|
751f29e340 | ||
|
|
2e629f8423 | ||
|
|
ae431f5b85 | ||
|
|
f5f697ceb0 | ||
|
|
02a8f0baf9 | ||
|
|
2932149b92 | ||
|
|
e1e4d67114 | ||
|
|
1e3f112d9c | ||
|
|
e3600949b0 | ||
|
|
856c80cc20 | ||
|
|
8c77796f91 | ||
|
|
dbeaaa4bac | ||
|
|
f9986e1b7f | ||
|
|
895008ba1d | ||
|
|
09791ae86e | ||
|
|
564dee512f | ||
|
|
56a9922057 | ||
|
|
44f0112620 | ||
|
|
2d7a5ce264 | ||
|
|
48f8d00d2e | ||
|
|
7afcf0f76b | ||
|
|
a7b28ce84d | ||
|
|
4ef82ac3a8 | ||
|
|
f86c826d1b | ||
|
|
3de7ef1a26 | ||
|
|
1713780528 | ||
|
|
6db39b829e | ||
|
|
174ff0886c | ||
|
|
d49d8f52c4 | ||
|
|
c9552e448a | ||
|
|
47d3059d78 | ||
|
|
c3bafef6d8 | ||
|
|
1fbbc935c1 | ||
|
|
ad0e6be10d | ||
|
|
5735d47889 | ||
|
|
294bdfa296 | ||
|
|
83eb012cb5 | ||
|
|
51d165fe84 | ||
|
|
3b7eb6c606 | ||
|
|
333146c590 | ||
|
|
6adb310f0c | ||
|
|
a64fe1c08c | ||
|
|
40cdb3d0ab | ||
|
|
7a97721abf | ||
|
|
c1b71e7772 | ||
|
|
56517226b6 | ||
|
|
337d270ba6 | ||
|
|
373ab315a3 | ||
|
|
685e1fc300 | ||
|
|
274aded8fd | ||
|
|
39b6b5e116 | ||
|
|
3f52d8c76e | ||
|
|
11f814777b | ||
|
|
dd66ecdb78 | ||
|
|
ec90a34262 | ||
|
|
5a2ce4968d | ||
|
|
03659b4a15 | ||
|
|
b032c96a7c | ||
|
|
d887d44bd4 | ||
|
|
7115ce8484 | ||
|
|
96cc9822d0 | ||
|
|
68fb74f441 | ||
|
|
06ed4f3517 | ||
|
|
3a4dd10b4c | ||
|
|
0316fdc0a3 | ||
|
|
72ecd7f054 | ||
|
|
8db995010f | ||
|
|
539fec4dbc | ||
|
|
3fa0f9b76d | ||
|
|
41d8b2c375 | ||
|
|
4fadfcfaf2 | ||
|
|
af3df7f72a | ||
|
|
3b8fb93fb2 | ||
|
|
2cd5bda416 | ||
|
|
965b215224 | ||
|
|
27f6b746e0 | ||
|
|
7d463d7da8 | ||
|
|
55be9374e1 | ||
|
|
ce1bade773 | ||
|
|
332540488d | ||
|
|
ab52ef6c27 | ||
|
|
9fe046617c | ||
|
|
01763a6b36 | ||
|
|
b28bb44f97 | ||
|
|
411d0b4565 | ||
|
|
2d55618898 | ||
|
|
6b3df405eb | ||
|
|
e91ef4c142 | ||
|
|
9b504ccd68 | ||
|
|
dd3f4411cf | ||
|
|
0ab94b6221 | ||
|
|
8477889cea | ||
|
|
2eb4a7a0fe | ||
|
|
1f68adda90 | ||
|
|
16697c442c | ||
|
|
ffba2c56a3 | ||
|
|
02be78a26e | ||
|
|
711d4613ed | ||
|
|
a5fe5225e1 | ||
|
|
b752d9437c | ||
|
|
18df92e048 | ||
|
|
4e66b8ed04 | ||
|
|
4f14d16452 | ||
|
|
592588ef76 | ||
|
|
3fec7c3392 | ||
|
|
2335caefd7 | ||
|
|
194eedeaa8 | ||
|
|
ecd09aedb9 | ||
|
|
b5350b5fe3 | ||
|
|
ceaafd5531 | ||
|
|
654accad4a | ||
|
|
173c7894b4 | ||
|
|
1ab51d15f9 | ||
|
|
ddce51d2d3 | ||
|
|
fc9fbf4357 | ||
|
|
5e8cd7e0e6 | ||
|
|
1eb5fbb983 | ||
|
|
d9ccb389f0 | ||
|
|
a3b7af3ba5 | ||
|
|
dc80d1d752 | ||
|
|
ff28a80d7c | ||
|
|
ab55ab349a | ||
|
|
a1ec7dbec2 | ||
|
|
e29846ae25 | ||
|
|
e38519aec2 | ||
|
|
33492b8f3a | ||
|
|
a620d7a9f7 | ||
|
|
88ebd79681 | ||
|
|
e08efa12fa | ||
|
|
7a685c0518 | ||
|
|
4deaaa2c8e | ||
|
|
adaf0f89fb | ||
|
|
47de5373b1 | ||
|
|
db9d23b5ec | ||
|
|
47c74931d4 | ||
|
|
be01031501 | ||
|
|
2e656d9c41 | ||
|
|
9071193c4d | ||
|
|
27e1da6363 | ||
|
|
e02c5496ae | ||
|
|
3fee236fd0 | ||
|
|
78ba42239d | ||
|
|
8ffb672e26 | ||
|
|
150ab83f73 | ||
|
|
83ddc934ba | ||
|
|
31cbd52703 | ||
|
|
dd85f7a25d | ||
|
|
35db7a4416 | ||
|
|
203c25aabe | ||
|
|
41b1c89283 | ||
|
|
1ce93af144 | ||
|
|
f410a8fdd5 | ||
|
|
180ea21041 | ||
|
|
e1c0fc6917 | ||
|
|
057c192314 | ||
|
|
88914169bc | ||
|
|
aff782aa28 | ||
|
|
8d51bcb74d | ||
|
|
2e729cb79c | ||
|
|
d8677d74a7 | ||
|
|
5a87529934 | ||
|
|
2f77714994 | ||
|
|
bc8050c4df | ||
|
|
a9c69b112d | ||
|
|
04785c323b | ||
|
|
1d209ceb28 | ||
|
|
941a9256f0 | ||
|
|
9098012426 | ||
|
|
8e12ff59a4 | ||
|
|
0f8a267861 | ||
|
|
da53a00fd8 | ||
|
|
47e3a93e42 | ||
|
|
0262a1c176 | ||
|
|
7aecd911c0 | ||
|
|
595ccadfb3 | ||
|
|
bded273f94 | ||
|
|
c095d393cf | ||
|
|
c3ae82fde7 | ||
|
|
a8e11ebef7 | ||
|
|
61b1a2e724 | ||
|
|
7a4af4e1e0 | ||
|
|
a9d6481193 | ||
|
|
fb97963cc3 | ||
|
|
0cc0ba989c | ||
|
|
0d7a41996e | ||
|
|
26eff02943 | ||
|
|
67246efafc | ||
|
|
dc97de03d0 | ||
|
|
260b3ba1f9 | ||
|
|
90f8885e73 | ||
|
|
0bacd6a65c | ||
|
|
2846deb4d0 | ||
|
|
ae6dbcab25 | ||
|
|
d823ec2e82 | ||
|
|
b0ce5273ce | ||
|
|
b576862024 | ||
|
|
dc2496ee18 | ||
|
|
5d4a1248e4 | ||
|
|
f876d8eb09 | ||
|
|
87db207833 | ||
|
|
b1ccf6ec5f | ||
|
|
ca3f63af81 | ||
|
|
29b0ca5bf5 | ||
|
|
da50193e8c | ||
|
|
1829c1fc1c | ||
|
|
79a17407bd | ||
|
|
dd1e3283d5 | ||
|
|
1d1d92a5cf | ||
|
|
d1baedd092 | ||
|
|
9e504cefc1 | ||
|
|
3cc5ab70fa | ||
|
|
0441af78a1 | ||
|
|
3c615a4fd7 | ||
|
|
8681e16fde | ||
|
|
918c6c448c | ||
|
|
1e2b4f16b3 | ||
|
|
f0d02eb8b3 | ||
|
|
96274206c5 | ||
|
|
7ae7ea6f79 | ||
|
|
487e7ad83d | ||
|
|
c9dabd39fe | ||
|
|
30ab689c6e | ||
|
|
8143af18af | ||
|
|
471c65cc48 | ||
|
|
9162d5afcc | ||
|
|
9236c664c2 | ||
|
|
767ed69020 | ||
|
|
93c706719f | ||
|
|
ed1ac1926b | ||
|
|
3adb6d01c0 | ||
|
|
9c195c1f30 | ||
|
|
e27093404b | ||
|
|
9602427ca0 | ||
|
|
46812cb5ff | ||
|
|
6258d92464 | ||
|
|
c57d913c46 | ||
|
|
5d285b7ac6 | ||
|
|
c96219f851 | ||
|
|
c7d949b1d1 | ||
|
|
6593a8e324 | ||
|
|
0988fa0115 | ||
|
|
9f0b1a172c | ||
|
|
0126a829dd | ||
|
|
b04a2750a3 | ||
|
|
9e22160b16 | ||
|
|
e8e4b093d2 | ||
|
|
0cb27f2637 | ||
|
|
1497c7385b | ||
|
|
327b06d2a1 | ||
|
|
2c2cfc0b73 | ||
|
|
93327b0a6a | ||
|
|
11c74d4117 | ||
|
|
f93bb6e33e | ||
|
|
73699bbda9 | ||
|
|
5f24ec2d86 | ||
|
|
c08bb8f8e0 | ||
|
|
d01e705866 | ||
|
|
ecc80c3c01 | ||
|
|
784a907384 | ||
|
|
b68a5f32ca | ||
|
|
467f9e2ffd | ||
|
|
1d25acde61 | ||
|
|
cb6ac86bb0 | ||
|
|
cd2da4441d | ||
|
|
d7515b172c | ||
|
|
e4de903cc9 | ||
|
|
45166a3b9c | ||
|
|
687b59e397 | ||
|
|
9a3be8d5a5 | ||
|
|
0c23c24651 | ||
|
|
e20e7f016c | ||
|
|
a0c1a24ba9 | ||
|
|
0475b3d01a | ||
|
|
39cb9b99f6 | ||
|
|
efd123ddfe | ||
|
|
413318886c | ||
|
|
187f9d2386 | ||
|
|
5f61c7baa6 | ||
|
|
e3c5ec9a58 | ||
|
|
fde52415cb | ||
|
|
d92225d093 | ||
|
|
e2a0932229 | ||
|
|
6acdf73557 | ||
|
|
4405b61e03 | ||
|
|
fba714d679 | ||
|
|
8e15282405 | ||
|
|
72b5816451 | ||
|
|
4016191497 | ||
|
|
94b36c1622 | ||
|
|
aa736b8b4a | ||
|
|
94cd937c56 | ||
|
|
9ccf3b7242 | ||
|
|
01b7a58c75 | ||
|
|
2b6c661fea | ||
|
|
fbd61cdf2f | ||
|
|
e9534181da | ||
|
|
fdc613fe8e | ||
|
|
fca09a84cb | ||
|
|
6ec6fb1f11 | ||
|
|
3d51d3548f | ||
|
|
8479b6c822 | ||
|
|
21bb165103 | ||
|
|
c7ca01c0fb | ||
|
|
5d2172f2bf | ||
|
|
1decd9d7a5 | ||
|
|
d0091cfed3 | ||
|
|
0ebcf1729c | ||
|
|
51d101e8f0 | ||
|
|
2d9bf225a0 | ||
|
|
f85d12448c | ||
|
|
82c8ad98b4 | ||
|
|
2499c7c426 | ||
|
|
da530217ab | ||
|
|
4c70d8aac7 | ||
|
|
ba1dbcc9f3 | ||
|
|
0ad380cc81 | ||
|
|
b77a2e374e | ||
|
|
647108e8ee | ||
|
|
8e4e41ca07 | ||
|
|
a8eb3f5f1a | ||
|
|
eb5ac5c8ea | ||
|
|
29344e0c48 | ||
|
|
73f7ac057c | ||
|
|
d359d53fb7 | ||
|
|
ff83ee9c62 | ||
|
|
da3a8d7fbe | ||
|
|
ba2fc55188 | ||
|
|
a6896c9b50 | ||
|
|
f2563c5e8d | ||
|
|
9326291780 | ||
|
|
8dd74f310d | ||
|
|
44ed9e9ca6 | ||
|
|
ab22680625 | ||
|
|
a73e29efc9 | ||
|
|
26273b7240 | ||
|
|
c925407f05 | ||
|
|
622fc369d0 | ||
|
|
0c4b7573cc | ||
|
|
401ce5c290 | ||
|
|
9580ad3934 | ||
|
|
b33c0810d5 | ||
|
|
e3e84e0d18 | ||
|
|
55cb4a7144 | ||
|
|
8fbe639aa1 | ||
|
|
9bc042e2cd | ||
|
|
67302c6a17 | ||
|
|
d7e2ec3b08 | ||
|
|
fdaca0e8a7 | ||
|
|
3af5f89606 | ||
|
|
3ccccfc5e5 | ||
|
|
7959f903bb | ||
|
|
59eb91f28f | ||
|
|
9aecfb22a5 | ||
|
|
ba4476a3a7 | ||
|
|
64724d2a92 | ||
|
|
be58ebdb7d | ||
|
|
746a9f5eb6 | ||
|
|
91a6d672fa | ||
|
|
50809476fa | ||
|
|
483f9cf192 | ||
|
|
f1c7dcda45 | ||
|
|
282ffb543c | ||
|
|
c840196a14 | ||
|
|
7d76037a43 | ||
|
|
68fea480ae | ||
|
|
b063e5c276 | ||
|
|
f2d98505f1 | ||
|
|
ec64ea8229 | ||
|
|
59dc181625 | ||
|
|
79387b83f6 | ||
|
|
b13433e554 | ||
|
|
d9fd382cb2 | ||
|
|
baa5e300c8 | ||
|
|
51747773ba | ||
|
|
4d324d885d | ||
|
|
af1a437523 | ||
|
|
dd8663591e | ||
|
|
de4f31f255 | ||
|
|
53c1bc9e59 | ||
|
|
77901ae1bc | ||
|
|
1aeff542e5 | ||
|
|
c05469d704 | ||
|
|
1e6bb542f9 | ||
|
|
b81ad079ee | ||
|
|
044edcda07 | ||
|
|
7eb85d2a99 | ||
|
|
7d4d6cd9df | ||
|
|
3f7eb4528e | ||
|
|
0008f70552 | ||
|
|
ff48517b43 | ||
|
|
2695715180 | ||
|
|
b3be1a561c | ||
|
|
cab7a7f8bb | ||
|
|
f6da5961c3 | ||
|
|
d297f1a27b | ||
|
|
b513509b01 | ||
|
|
bfa3f7e04e | ||
|
|
539b516e45 | ||
|
|
25744efc66 | ||
|
|
21e37366fb | ||
|
|
6002ad44e1 | ||
|
|
a431270718 | ||
|
|
3f6a47c39d | ||
|
|
4dace0a32b | ||
|
|
efb5318fc5 | ||
|
|
a5e83f3133 | ||
|
|
7b6ca12744 | ||
|
|
da1b5a969c | ||
|
|
5d790231ea | ||
|
|
ad363ce38c | ||
|
|
8e5d6eec6c | ||
|
|
92f1400a35 | ||
|
|
47f1938136 | ||
|
|
13d3cfb290 | ||
|
|
c657bdfb63 | ||
|
|
f226218b3a | ||
|
|
8f7fa0dea1 | ||
|
|
292b3c805d | ||
|
|
ded26f92af | ||
|
|
8cce7bbaf4 | ||
|
|
228b8c3ec7 | ||
|
|
2e298ce757 | ||
|
|
81daa9f0b0 | ||
|
|
713c8d9177 | ||
|
|
f571162eed | ||
|
|
19d2872943 | ||
|
|
0752b8bf7f | ||
|
|
c01097196a | ||
|
|
5f1bee018d | ||
|
|
8655b81618 | ||
|
|
1702e43373 | ||
|
|
a7d411f6ff | ||
|
|
6a84da0f96 | ||
|
|
79425f4490 | ||
|
|
2fcddea0fa | ||
|
|
ac1c8552e1 | ||
|
|
6a18eefe34 | ||
|
|
a43eedaf9f | ||
|
|
77bc24990e | ||
|
|
edbc79c89e | ||
|
|
527a090f1b | ||
|
|
370b86337f | ||
|
|
1ce3acdde0 | ||
|
|
1a72be323b | ||
|
|
bf252f22f2 | ||
|
|
937d92f686 | ||
|
|
5572957731 | ||
|
|
d2db5722b7 | ||
|
|
5404185a6c | ||
|
|
800f2dfbbb | ||
|
|
ed0b5410f6 | ||
|
|
ae72e262aa | ||
|
|
c4c7050c3f | ||
|
|
81e5227270 | ||
|
|
39319d9162 | ||
|
|
12206fa856 | ||
|
|
9d8c6e26f1 | ||
|
|
eb624672e6 | ||
|
|
1b026b5f08 | ||
|
|
21b9b8b44d | ||
|
|
6ef7fcc0c2 | ||
|
|
fc4ce178cb | ||
|
|
98b72988c4 | ||
|
|
317473e528 | ||
|
|
e13c5ddf51 | ||
|
|
90be3ab731 | ||
|
|
85c0445a64 | ||
|
|
6a227ccd7c | ||
|
|
3d9a21f854 | ||
|
|
d4229a985d | ||
|
|
c6e0921dea | ||
|
|
346a8a4375 | ||
|
|
4799faa827 | ||
|
|
2c6f5c38ed | ||
|
|
4a884ab5cc | ||
|
|
e59beaf749 | ||
|
|
0d4e1229ab | ||
|
|
8810fd91c9 | ||
|
|
095444610d | ||
|
|
7cf43c5e7c | ||
|
|
77183af08d | ||
|
|
3a2c5be5dd | ||
|
|
11c764c2f3 | ||
|
|
d128234ae6 | ||
|
|
ee31ee225d | ||
|
|
e2195616d3 | ||
|
|
2ca818b996 | ||
|
|
131f2b4538 | ||
|
|
6731c3fea9 | ||
|
|
d3da5e8c13 | ||
|
|
3b0d4225ce | ||
|
|
66f0930164 | ||
|
|
3979b83a0a | ||
|
|
b65e432564 | ||
|
|
340e7c3e00 | ||
|
|
dcdce63e15 | ||
|
|
c2eaed757d | ||
|
|
920743bea7 | ||
|
|
0970f5baae | ||
|
|
291aa13e9a | ||
|
|
e644cde514 | ||
|
|
2350e68f4f | ||
|
|
cce3b681e3 | ||
|
|
755799f19d | ||
|
|
ec02dc8579 | ||
|
|
7028982ba1 | ||
|
|
f04503e38e | ||
|
|
f1edffed17 | ||
|
|
fac87acee7 | ||
|
|
c46dd91ede | ||
|
|
648753f8cf | ||
|
|
76b3bda313 | ||
|
|
8102dd1a8f | ||
|
|
aceb4e7f4c | ||
|
|
4224389e96 | ||
|
|
7c66c10b6d | ||
|
|
bf06a2e50c | ||
|
|
8c69fdef95 | ||
|
|
883c1d2f57 | ||
|
|
e7f1652b4e | ||
|
|
ea07224a52 | ||
|
|
b655154182 | ||
|
|
a173892163 | ||
|
|
14f48c4a35 | ||
|
|
88aa4daf74 | ||
|
|
25326dc9be | ||
|
|
030cd13f7c | ||
|
|
d557620c81 | ||
|
|
3273f8dd11 | ||
|
|
646719557c | ||
|
|
1640b326be | ||
|
|
cc19c9ec19 | ||
|
|
bb84ab9697 | ||
|
|
0f7664201a | ||
|
|
0b8a8213e6 | ||
|
|
476b3da66b | ||
|
|
41c632ccfa | ||
|
|
dbf5addf12 | ||
|
|
8d414080a4 | ||
|
|
c8d390726c | ||
|
|
31285dd0d6 | ||
|
|
b9ef58044e | ||
|
|
e320f74dfc | ||
|
|
b9deb3cdf0 | ||
|
|
5dbf293e1e | ||
|
|
47f2308acc | ||
|
|
496be692e7 | ||
|
|
5df6dd169a | ||
|
|
7232f17cb7 | ||
|
|
5b1c3d0606 | ||
|
|
8e6ca64fa2 | ||
|
|
9116b4d126 | ||
|
|
4ce38d9857 | ||
|
|
8811c48384 | ||
|
|
078d0d1ee2 | ||
|
|
56d755afa6 | ||
|
|
9e3abb366f | ||
|
|
4ebac1c46f | ||
|
|
1476df7a1f | ||
|
|
833872603a | ||
|
|
737834310b | ||
|
|
4474c8ee5e | ||
|
|
ebe5c63a7b | ||
|
|
afd98ae652 | ||
|
|
8f905c8128 | ||
|
|
f43f30c9b9 | ||
|
|
6b1bea9b17 | ||
|
|
cdaed9766e | ||
|
|
d31a9ab2d2 | ||
|
|
07d49749fb | ||
|
|
a1f1da8a07 | ||
|
|
1b791fd287 | ||
|
|
2a4af58a51 | ||
|
|
b12f8ca044 | ||
|
|
519b3abd1d | ||
|
|
e08a5bd6b6 | ||
|
|
f6c597b42d | ||
|
|
f3b9032174 | ||
|
|
44b4ee2953 | ||
|
|
b77f79f42d | ||
|
|
8017bf7c8a | ||
|
|
85f4cda752 | ||
|
|
1fd2f553ed | ||
|
|
d68170eda8 | ||
|
|
51f27226e7 | ||
|
|
d54c366323 | ||
|
|
0e7cb9fbab | ||
|
|
63e83032f1 | ||
|
|
b4da6fdd9f | ||
|
|
12535a59e8 | ||
|
|
b345eda7aa | ||
|
|
660c1dcadf | ||
|
|
be66856034 | ||
|
|
80e3027107 | ||
|
|
3bf731055f | ||
|
|
362ca53efb | ||
|
|
7ab213e5e4 | ||
|
|
f0eafb1162 | ||
|
|
ad5baa7d6f | ||
|
|
5840e30afb | ||
|
|
8f95606dd7 | ||
|
|
ca9bc7ae94 | ||
|
|
288a40e697 | ||
|
|
fa91e8c289 | ||
|
|
4ab5fe4c6f | ||
|
|
d87782d180 | ||
|
|
1c97c97fa1 | ||
|
|
2426166676 | ||
|
|
6f6c1c7c52 | ||
|
|
7b9cb2ecb9 | ||
|
|
fc029af654 | ||
|
|
110a69dc4b | ||
|
|
a61791fdb2 | ||
|
|
c01440f9d4 | ||
|
|
39584dd7a1 | ||
|
|
d9badeacba | ||
|
|
d4cde2ba9c | ||
|
|
824ef4528e | ||
|
|
feb06a85ae | ||
|
|
cfad645e33 | ||
|
|
675f8dec20 | ||
|
|
11e85b613c | ||
|
|
eb82833c7e | ||
|
|
399a3e09de | ||
|
|
1a735bda42 | ||
|
|
991196cc30 | ||
|
|
5f48903a73 | ||
|
|
5dfe1b758e | ||
|
|
d24e534828 | ||
|
|
80bdca138e | ||
|
|
0a2ca8c0b6 | ||
|
|
921a355ae0 | ||
|
|
eff7b24b7a | ||
|
|
c328620f3d | ||
|
|
a49b6d6573 | ||
|
|
baffd2efaf | ||
|
|
292693a1ed | ||
|
|
cdd01e695c | ||
|
|
614a9e057c | ||
|
|
24d90bb750 | ||
|
|
c2726e6b18 | ||
|
|
a746ce9169 | ||
|
|
c00b967179 | ||
|
|
f6e749ecf9 | ||
|
|
8f5e0686c8 | ||
|
|
b9f0d9279b | ||
|
|
cd69f0c82f | ||
|
|
884746f3a5 | ||
|
|
9c4d862297 | ||
|
|
cc87cf68b5 | ||
|
|
db1a0235ed | ||
|
|
2cd6a7cae0 | ||
|
|
bd80fdc960 | ||
|
|
7c5d57ddc1 | ||
|
|
5653ff18c8 | ||
|
|
83ea444675 | ||
|
|
966edfa85a | ||
|
|
30b9fc7765 | ||
|
|
95a60e9a96 | ||
|
|
9327331adc | ||
|
|
e8baa441cd | ||
|
|
61d142afbd | ||
|
|
46022e8711 | ||
|
|
e59f3c46fd | ||
|
|
7fcb3f2c31 | ||
|
|
a2243585a0 | ||
|
|
6231002043 | ||
|
|
33eb476009 | ||
|
|
ebb0a4c7e5 | ||
|
|
d010ed6bc5 | ||
|
|
1941e29175 | ||
|
|
6e8932ff52 | ||
|
|
f9bfae9b9d | ||
|
|
2fca928cf0 | ||
|
|
8f3a362a3a | ||
|
|
62a6189788 | ||
|
|
2a6d631719 | ||
|
|
4942591d7f | ||
|
|
1760c1b852 | ||
|
|
86d7ab9864 | ||
|
|
f201e4593a | ||
|
|
71129e556e | ||
|
|
0e7ebb79db | ||
|
|
727d9eb57c | ||
|
|
228ff75468 | ||
|
|
80018cac08 | ||
|
|
b2c19e7ed0 | ||
|
|
a64a80910d | ||
|
|
4eeeef3427 | ||
|
|
657586ddff | ||
|
|
797b287899 | ||
|
|
db2d95f2c5 | ||
|
|
16a8f259ab | ||
|
|
ee1cd8d08d | ||
|
|
e53046324c | ||
|
|
dd71977317 | ||
|
|
bee0da1454 | ||
|
|
b60feaa447 | ||
|
|
743e54614a | ||
|
|
bd96713af4 | ||
|
|
8f7f9b457a | ||
|
|
4c0d68eab8 | ||
|
|
8211407cf2 | ||
|
|
5fb6c7d36a | ||
|
|
30b1cf4493 | ||
|
|
f4789ac5fe | ||
|
|
5fd826ae71 | ||
|
|
22ce08f06c | ||
|
|
696c88f03f | ||
|
|
9d5d87e47b | ||
|
|
e32539e02c | ||
|
|
46ba931a6c | ||
|
|
4bac5143d6 | ||
|
|
bcb1674491 | ||
|
|
3cbd9677c2 | ||
|
|
b216ee1a80 | ||
|
|
32ec482449 | ||
|
|
5074748055 | ||
|
|
6fa11f834b | ||
|
|
25c7702127 | ||
|
|
b546556d08 | ||
|
|
379a85f6f6 | ||
|
|
92d2c14d6a | ||
|
|
a6c1f7d3f2 | ||
|
|
6fa6187cc4 | ||
|
|
848e7a1609 | ||
|
|
3e8c0a2896 | ||
|
|
11900b1500 | ||
|
|
5668a1411f | ||
|
|
6db60f111d | ||
|
|
5c682addc9 | ||
|
|
24810cc818 | ||
|
|
8caeb11da5 | ||
|
|
5e07b446fc | ||
|
|
760388307c | ||
|
|
eebd8500ee | ||
|
|
12112afa37 | ||
|
|
cddb0a7e05 | ||
|
|
21b317e4f1 |
+67
-21
@@ -1,37 +1,83 @@
|
|||||||
# Environnement
|
# Environment
|
||||||
NODE_ENV=production
|
NODE_ENV=development
|
||||||
|
|
||||||
# Base de données
|
# Logging
|
||||||
DATABASE_URL=postgresql://devuser:changeme@db:5432/devdb?schema=public
|
# Controls minimum log level. Options: debug | info | warn | error
|
||||||
|
# Set to "warn" or "error" in production to suppress health-check ping noise.
|
||||||
|
# Use "debug" to see all request logs including agent status pings.
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
# Projet
|
# Database
|
||||||
|
DATABASE_URL=postgresql://devuser:changeme@localhost:5433/devdb?schema=public
|
||||||
|
|
||||||
|
# Project
|
||||||
PROJECT_NAME="Portabase"
|
PROJECT_NAME="Portabase"
|
||||||
PROJECT_DESCRIPTION="Portabase is a powerful database manager"
|
PROJECT_URL=http://localhost:8887
|
||||||
PROJECT_URL=http://app.portabase.io
|
|
||||||
PROJECT_SECRET=
|
PROJECT_SECRET=
|
||||||
|
|
||||||
|
AUTH_DEFAULT_USER_NAME="Portabase Admin"
|
||||||
|
AUTH_DEFAULT_USER=user@example.com
|
||||||
|
# Password must contain at least 8 characters
|
||||||
|
# Password must contain at least 1 number
|
||||||
|
# Password must contain at least 1 lowercase letter
|
||||||
|
# Password must contain at least 1 uppercase letter
|
||||||
|
# Password must contain at least 1 special character
|
||||||
|
AUTH_DEFAULT_PASSWORD=testPASS123456!
|
||||||
|
|
||||||
# SMTP (email)
|
# SMTP (email)
|
||||||
SMTP_HOST=
|
SMTP_HOST=
|
||||||
SMTP_PORT=587
|
SMTP_PORT=
|
||||||
SMTP_USER=
|
SMTP_USER=
|
||||||
SMTP_PASSWORD=
|
SMTP_PASSWORD=
|
||||||
SMTP_FROM=
|
SMTP_FROM=
|
||||||
|
#SMTP_SECURE="true"
|
||||||
|
#Port 587 -> secure: false
|
||||||
|
#Port 465 -> secure: true
|
||||||
|
#By default : true
|
||||||
|
|
||||||
# Google
|
# OIDC SSO Authentification
|
||||||
AUTH_GOOGLE_ID=
|
AUTH_OIDC_ID=""
|
||||||
AUTH_GOOGLE_SECRET=
|
AUTH_OIDC_TITLE=""
|
||||||
AUTH_GOOGLE_METHOD=
|
AUTH_OIDC_DESC=""
|
||||||
|
AUTH_OIDC_ICON=""
|
||||||
|
AUTH_OIDC_CLIENT=""
|
||||||
|
AUTH_OIDC_SECRET=""
|
||||||
|
AUTH_OIDC_ISSUER_URL=""
|
||||||
|
AUTH_OIDC_HOST=""
|
||||||
|
AUTH_OIDC_SCOPES=""
|
||||||
|
AUTH_OIDC_DISCOVERY_ENDPOINT=""
|
||||||
|
AUTH_OIDC_JWKS_ENDPOINT=""
|
||||||
|
AUTH_OIDC_PKCE=true
|
||||||
|
ALLOWED_GROUP="admin"
|
||||||
|
AUTH_OIDC_ROLE_MAP=""
|
||||||
|
|
||||||
# S3
|
AUTH_ALLOW_LINKING=true
|
||||||
S3_ENDPOINT=http://app.s3.portabase.io
|
AUTH_ALLOW_UNLINKING=true
|
||||||
S3_ACCESS_KEY=
|
|
||||||
S3_SECRET_KEY=
|
|
||||||
S3_BUCKET_NAME=portabase
|
|
||||||
S3_PORT=9000
|
|
||||||
S3_USE_SSL=true
|
|
||||||
|
|
||||||
# Storage Type (s3, local)
|
# Social OAuth2 Authentification
|
||||||
STORAGE_TYPE=local
|
AUTH_SOCIAL_ID=""
|
||||||
|
AUTH_SOCIAL_TITLE=""
|
||||||
|
AUTH_SOCIAL_DESC=""
|
||||||
|
AUTH_SOCIAL_ICON=""
|
||||||
|
AUTH_SOCIAL_CLIENT=""
|
||||||
|
AUTH_SOCIAL_SECRET=""
|
||||||
|
AUTH_SOCIAL_APPLE_APP_BUNDLE_IDENTIFIER=""
|
||||||
|
|
||||||
|
# If true, the user's role will be updated from OIDC claims on every login.
|
||||||
|
# If false, the role is only set when the user is first created.
|
||||||
|
AUTH_SYNC_OIDC_ROLES_ON_LOGIN=true
|
||||||
|
AUTH_EMAIL_PASSWORD_ENABLED=true
|
||||||
|
AUTH_SIGNUP_ENABLED=true
|
||||||
|
AUTH_PASSKEY_ENABLED=true
|
||||||
|
|
||||||
# Retention
|
# Retention
|
||||||
RETENTION_CRON="* * * * *"
|
RETENTION_CRON="* * * * *"
|
||||||
|
|
||||||
|
TRUSTED_DOMAINS="http://localhost:8887, http://localhost:3055, http://localhost:3056"
|
||||||
|
|
||||||
|
#OPENAPI_ENABLED=true
|
||||||
|
#API_ENABLED=true
|
||||||
|
#MCP_ENABLED=true
|
||||||
|
|
||||||
|
# Default to false
|
||||||
|
#TUSD_BEHIND_PROXY=true
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ representative at an online or offline event.
|
|||||||
|
|
||||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||||
reported to the community leaders responsible for enforcement at
|
reported to the community leaders responsible for enforcement at
|
||||||
contact@soluce-technologies.com.
|
contact@portabase.io.
|
||||||
All complaints will be reviewed and investigated promptly and fairly.
|
All complaints will be reviewed and investigated promptly and fairly.
|
||||||
|
|
||||||
All community leaders are obligated to respect the privacy and security of the
|
All community leaders are obligated to respect the privacy and security of the
|
||||||
|
|||||||
+14
-5
@@ -14,8 +14,9 @@ Please take a moment to review this guide. It will help you understand how to co
|
|||||||
2. [Reporting Issues](#reporting-issues)
|
2. [Reporting Issues](#reporting-issues)
|
||||||
3. [Submitting Changes](#submitting-changes)
|
3. [Submitting Changes](#submitting-changes)
|
||||||
4. [Code Style Guidelines](#code-style-guidelines)
|
4. [Code Style Guidelines](#code-style-guidelines)
|
||||||
5. [Pull Request Process](#pull-request-process)
|
5. [E2E Tests](#e2e-tests)
|
||||||
6. [Community Guidelines](#community-guidelines)
|
6. [Pull Request Process](#pull-request-process)
|
||||||
|
7. [Community Guidelines](#community-guidelines)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -72,7 +73,7 @@ If you encounter a bug or have a suggestion for improvement, follow these steps:
|
|||||||
|
|
||||||
3. **Push your branch**
|
3. **Push your branch**
|
||||||
```bash
|
```bash
|
||||||
git push origin feature/<feature-name>
|
git push origin feat/<feature-name>
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Open a Pull Request (PR)**
|
4. **Open a Pull Request (PR)**
|
||||||
@@ -82,12 +83,20 @@ If you encounter a bug or have a suggestion for improvement, follow these steps:
|
|||||||
|
|
||||||
## Code Style Guidelines
|
## Code Style Guidelines
|
||||||
|
|
||||||
- Follow the [specific coding style guide] (e.g., Prettier, ESLint, PEP8).
|
- Follow the [specific coding style guide] (e.g., Prettier, ESLint, PEP8,Biome).
|
||||||
- Use meaningful variable names and include comments where necessary.
|
- Use meaningful variable names and include comments where necessary.
|
||||||
- Tests before submitting your changes.
|
- Tests before submitting your changes.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## E2E Tests
|
||||||
|
|
||||||
|
E2E tests now live in a dedicated repository: [Portabase/e2e-tests](https://github.com/Portabase/e2e-tests).
|
||||||
|
|
||||||
|
If your contribution needs corresponding end-to-end test coverage, add it there instead of this repo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Pull Request Process
|
## Pull Request Process
|
||||||
|
|
||||||
1. Ensure your code passes all tests and linters.
|
1. Ensure your code passes all tests and linters.
|
||||||
@@ -107,4 +116,4 @@ If you encounter a bug or have a suggestion for improvement, follow these steps:
|
|||||||
|
|
||||||
Thank you for contributing! 🙌
|
Thank you for contributing! 🙌
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||||
|
patreon: # Replace with a single Patreon username
|
||||||
|
open_collective: # Replace with a single Open Collective username
|
||||||
|
ko_fi: # Replace with a single Ko-fi username
|
||||||
|
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||||
|
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||||
|
liberapay: # Replace with a single Liberapay username
|
||||||
|
issuehunt: # Replace with a single IssueHunt username
|
||||||
|
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||||
|
polar: # Replace with a single Polar username
|
||||||
|
buy_me_a_coffee: portabase
|
||||||
|
thanks_dev: # Replace with a single thanks.dev username
|
||||||
|
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||||
+1
-1
@@ -18,7 +18,7 @@ We take security seriously and aim to support the following versions of the proj
|
|||||||
If you discover a security vulnerability in this project, we appreciate your help in disclosing it responsibly.
|
If you discover a security vulnerability in this project, we appreciate your help in disclosing it responsibly.
|
||||||
|
|
||||||
1. **Contact Us**
|
1. **Contact Us**
|
||||||
Please report the vulnerability by emailing **[contact@soluce-technologies.com](mailto:contact@soluce-technologies.com)**. Include the following details:
|
Please report the vulnerability by emailing **[contact@portabase.io](mailto:contact@portabase.io)**. Include the following details:
|
||||||
- A detailed description of the issue.
|
- A detailed description of the issue.
|
||||||
- Steps to reproduce the vulnerability (if applicable).
|
- Steps to reproduce the vulnerability (if applicable).
|
||||||
- Any potential impacts or risks.
|
- Any potential impacts or risks.
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
name: Discord Notification
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
release_tag:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
discord_title:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
discord_color:
|
||||||
|
required: true
|
||||||
|
type: number
|
||||||
|
discord_footer:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
secrets:
|
||||||
|
DISCORD_WEBHOOK:
|
||||||
|
required: true
|
||||||
|
GH_TOKEN:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
notify-discord:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Send Discord Notification
|
||||||
|
env:
|
||||||
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
|
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||||
|
RELEASE_TAG: ${{ inputs.release_tag }}
|
||||||
|
DISCORD_TITLE: ${{ inputs.discord_title }}
|
||||||
|
DISCORD_COLOR: ${{ inputs.discord_color }}
|
||||||
|
DISCORD_FOOTER: ${{ inputs.discord_footer }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
RELEASE_INFO=$(gh release view "$RELEASE_TAG" -R "${{ github.repository }}" --json name,url,body,author)
|
||||||
|
|
||||||
|
RELEASE_TITLE=$(echo "$RELEASE_INFO" | jq -r '.name // empty')
|
||||||
|
if [ -z "$RELEASE_TITLE" ]; then
|
||||||
|
RELEASE_TITLE="$RELEASE_TAG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
RELEASE_URL=$(echo "$RELEASE_INFO" | jq -r '.url // empty')
|
||||||
|
RELEASE_BODY=$(echo "$RELEASE_INFO" | jq -r '.body // ""')
|
||||||
|
|
||||||
|
AUTHOR_NAME="Portabase"
|
||||||
|
AUTHOR_ICON="https://github.com/Portabase.png"
|
||||||
|
|
||||||
|
jq -n \
|
||||||
|
--arg title "$RELEASE_TITLE" \
|
||||||
|
--arg description "$RELEASE_BODY" \
|
||||||
|
--arg url "$RELEASE_URL" \
|
||||||
|
--arg author "$AUTHOR_NAME" \
|
||||||
|
--arg icon "$AUTHOR_ICON" \
|
||||||
|
--arg discord_title "$DISCORD_TITLE" \
|
||||||
|
--arg discord_footer "$DISCORD_FOOTER" \
|
||||||
|
--argjson discord_color "$DISCORD_COLOR" \
|
||||||
|
'{
|
||||||
|
content: (
|
||||||
|
if ($discord_title | length) > 1900
|
||||||
|
then ($discord_title[0:1900] + "...")
|
||||||
|
else $discord_title
|
||||||
|
end
|
||||||
|
),
|
||||||
|
embeds: [{
|
||||||
|
title: (
|
||||||
|
if ($title | length) > 256
|
||||||
|
then ($title[0:253] + "...")
|
||||||
|
else $title
|
||||||
|
end
|
||||||
|
),
|
||||||
|
url: $url,
|
||||||
|
description: (
|
||||||
|
if ($description | length) > 3800
|
||||||
|
then ($description[0:3800] + "\n\n...")
|
||||||
|
else $description
|
||||||
|
end
|
||||||
|
),
|
||||||
|
color: $discord_color,
|
||||||
|
author: {
|
||||||
|
name: (
|
||||||
|
if ($author | length) > 256
|
||||||
|
then ($author[0:253] + "...")
|
||||||
|
else $author
|
||||||
|
end
|
||||||
|
),
|
||||||
|
icon_url: $icon
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
text: (
|
||||||
|
if ($discord_footer | length) > 512
|
||||||
|
then ($discord_footer[0:509] + "...")
|
||||||
|
else $discord_footer
|
||||||
|
end
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}' > payload.json
|
||||||
|
|
||||||
|
cat payload.json | jq .
|
||||||
|
|
||||||
|
curl --fail-with-body -sS \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d @payload.json \
|
||||||
|
"$DISCORD_WEBHOOK?wait=true"
|
||||||
@@ -3,14 +3,16 @@ name: Docker Publish
|
|||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
inputs:
|
||||||
|
version:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
ref:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
image_name:
|
image_name:
|
||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
default: 'solucetechnologies/portabase'
|
default: "portabase/portabase"
|
||||||
image_name2:
|
|
||||||
required: false
|
|
||||||
type: string
|
|
||||||
default: 'portabase/portabase'
|
|
||||||
add_latest:
|
add_latest:
|
||||||
required: false
|
required: false
|
||||||
type: boolean
|
type: boolean
|
||||||
@@ -18,73 +20,123 @@ on:
|
|||||||
target:
|
target:
|
||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
default: 'prod'
|
default: "prod"
|
||||||
dockerfile:
|
dockerfile:
|
||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
default: './docker/dockerfile/Dockerfile'
|
default: "./docker/dockerfile/Dockerfile"
|
||||||
secrets:
|
secrets:
|
||||||
DOCKER_USERNAME:
|
DOCKER_USERNAME:
|
||||||
required: true
|
required: true
|
||||||
DOCKER_PASSWORD:
|
DOCKER_PASSWORD:
|
||||||
required: true
|
required: true
|
||||||
DOCKER_USERNAME_2:
|
|
||||||
required: true
|
|
||||||
DOCKER_PASSWORD_2:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build:
|
||||||
runs-on: ubuntu-latest
|
name: Build architectures
|
||||||
permissions:
|
runs-on: ${{ matrix.platform == 'linux/amd64' && 'ubuntu-latest' || 'ubuntu-24.04-arm' }}
|
||||||
packages: write
|
strategy:
|
||||||
contents: read
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
platform: [ linux/amd64, linux/arm64 ]
|
||||||
steps:
|
steps:
|
||||||
- name: Check out the repo
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
|
ref: ${{ inputs.ref }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set tags
|
- name: Set up Docker Buildx
|
||||||
id: set-tags
|
uses: docker/setup-buildx-action@v3
|
||||||
run: |
|
|
||||||
REF_NAME=${GITHUB_REF#refs/tags/}
|
|
||||||
TAGS="${{ inputs.image_name }}:$REF_NAME"
|
|
||||||
TAGS2="${{ inputs.image_name2 }}:$REF_NAME"
|
|
||||||
if [[ "${{ inputs.add_latest }}" == "true" ]]; then
|
|
||||||
TAGS="$TAGS,${{ inputs.image_name }}:latest"
|
|
||||||
TAGS2="$TAGS2,${{ inputs.image_name }}:latest"
|
|
||||||
fi
|
|
||||||
echo "tags=$TAGS" >> $GITHUB_OUTPUT
|
|
||||||
echo "tags2=$TAGS2" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
|
- name: Login to Docker
|
||||||
- name: Log in to Docker Hub account 1
|
uses: docker/login-action@v3
|
||||||
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
|
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
- name: Build and push to solucetechnologies/portabase
|
- name: Prepare Image Tags
|
||||||
|
id: prep
|
||||||
|
run: |
|
||||||
|
ARCH=${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
|
||||||
|
echo "image=${{ inputs.image_name }}:${{inputs.version}}-$ARCH" >> $GITHUB_OUTPUT
|
||||||
|
echo "safe_platform=${{ matrix.platform == 'linux/amd64' && 'linux-amd64' || 'linux-arm64' }}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
|
||||||
|
- name: Build and push image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ${{ inputs.dockerfile }}
|
file: ${{ inputs.dockerfile }}
|
||||||
|
platforms: ${{ matrix.platform }}
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.set-tags.outputs.tags }}
|
tags: ${{ steps.prep.outputs.image }}
|
||||||
target: ${{ inputs.target }}
|
target: ${{ inputs.target }}
|
||||||
|
cache-from: type=gha,scope=build-${{ matrix.platform }}
|
||||||
|
cache-to: type=gha,mode=max,scope=build-${{ matrix.platform }}
|
||||||
|
|
||||||
- name: Log in to Docker Hub account 2
|
- name: Save image name for manifest
|
||||||
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
|
run: echo "${{ steps.prep.outputs.image }}" > image.txt
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKER_USERNAME_2 }}
|
|
||||||
password: ${{ secrets.DOCKER_PASSWORD_2 }}
|
|
||||||
|
|
||||||
- name: Build and push to portabase/portabase
|
- uses: actions/upload-artifact@v4
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
with:
|
||||||
context: .
|
name: image-${{ steps.prep.outputs.safe_platform }}
|
||||||
file: ${{ inputs.dockerfile }}
|
path: image.txt
|
||||||
push: true
|
retention-days: 1
|
||||||
tags: ${{ steps.set-tags.outputs.tags2 }}
|
|
||||||
target: ${{ inputs.target }}
|
create-manifest:
|
||||||
|
name: Create multi-arch manifest
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build
|
||||||
|
steps:
|
||||||
|
- uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: image-linux-amd64
|
||||||
|
path: /tmp/digests/amd64
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: image-linux-arm64
|
||||||
|
path: /tmp/digests/arm64
|
||||||
|
|
||||||
|
- name: Login to Docker
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Generate semantic tags
|
||||||
|
id: tags
|
||||||
|
run: |
|
||||||
|
VERSION="${{ inputs.version }}"
|
||||||
|
IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION"
|
||||||
|
|
||||||
|
echo "VERSION_TAG=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
if [ -n "$MINOR" ]; then
|
||||||
|
echo "MINOR_TAG=$MAJOR.$MINOR" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
if [ -n "$MAJOR" ]; then
|
||||||
|
echo "MAJOR_TAG=$MAJOR" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
if [ "${{ inputs.add_latest }}" = "true" ]; then
|
||||||
|
echo "LATEST_TAG=latest" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Extract Docker metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ inputs.image_name }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=${{ steps.tags.outputs.VERSION_TAG }}
|
||||||
|
type=raw,value=${{ steps.tags.outputs.MINOR_TAG }}
|
||||||
|
type=raw,value=${{ steps.tags.outputs.MAJOR_TAG }}
|
||||||
|
type=raw,value=${{ steps.tags.outputs.LATEST_TAG }}
|
||||||
|
|
||||||
|
- name: Create and push manifest list
|
||||||
|
working-directory: /tmp/digests
|
||||||
|
run: |
|
||||||
|
DOCKER_IMAGES="$(cat amd64/image.txt) $(cat arm64/image.txt)"
|
||||||
|
TAG_ARGS=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON")
|
||||||
|
echo $TAG_ARGS
|
||||||
|
docker buildx imagetools create $TAG_ARGS $DOCKER_IMAGES
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
name: End-to-end testing
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-image:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||||
|
outputs:
|
||||||
|
image: ${{ steps.image.outputs.ref }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Compute image reference
|
||||||
|
id: image
|
||||||
|
run: echo "ref=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/portabase:${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./docker/dockerfile/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.image.outputs.ref }}
|
||||||
|
target: prod
|
||||||
|
|
||||||
|
run-e2e:
|
||||||
|
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||||
|
needs: build-image
|
||||||
|
uses: portabase/e2e-tests/.github/workflows/run-e2e.yml@main
|
||||||
|
with:
|
||||||
|
server_image: ${{ needs.build-image.outputs.image }}
|
||||||
|
secrets:
|
||||||
|
E2E_NOTIFICATION_SMTP_HOST: ${{ secrets.E2E_NOTIFICATION_SMTP_HOST }}
|
||||||
|
E2E_NOTIFICATION_SMTP_PORT: ${{ secrets.E2E_NOTIFICATION_SMTP_PORT }}
|
||||||
|
E2E_NOTIFICATION_SMTP_USER: ${{ secrets.E2E_NOTIFICATION_SMTP_USER }}
|
||||||
|
E2E_NOTIFICATION_SMTP_PASSWORD: ${{ secrets.E2E_NOTIFICATION_SMTP_PASSWORD }}
|
||||||
|
E2E_NOTIFICATION_SMTP_FROM: ${{ secrets.E2E_NOTIFICATION_SMTP_FROM }}
|
||||||
|
E2E_NOTIFICATION_SMTP_TO: ${{ secrets.E2E_NOTIFICATION_SMTP_TO }}
|
||||||
|
E2E_NOTIFICATION_SLACK_WEBHOOK: ${{ secrets.E2E_NOTIFICATION_SLACK_WEBHOOK }}
|
||||||
|
E2E_NOTIFICATION_DISCORD_WEBHOOK: ${{ secrets.E2E_NOTIFICATION_DISCORD_WEBHOOK }}
|
||||||
|
E2E_NOTIFICATION_TELEGRAM_BOT_TOKEN: ${{ secrets.E2E_NOTIFICATION_TELEGRAM_BOT_TOKEN }}
|
||||||
|
E2E_NOTIFICATION_TELEGRAM_CHAT_ID: ${{ secrets.E2E_NOTIFICATION_TELEGRAM_CHAT_ID }}
|
||||||
|
E2E_NOTIFICATION_TELEGRAM_TOPIC_ID: ${{ secrets.E2E_NOTIFICATION_TELEGRAM_TOPIC_ID }}
|
||||||
|
E2E_NOTIFICATION_GOTIFY_SERVER_URL: ${{ secrets.E2E_NOTIFICATION_GOTIFY_SERVER_URL }}
|
||||||
|
E2E_NOTIFICATION_GOTIFY_APP_TOKEN: ${{ secrets.E2E_NOTIFICATION_GOTIFY_APP_TOKEN }}
|
||||||
|
E2E_NOTIFICATION_NTFY_TOPIC: ${{ secrets.E2E_NOTIFICATION_NTFY_TOPIC }}
|
||||||
|
E2E_NOTIFICATION_NTFY_SERVER_URL: ${{ secrets.E2E_NOTIFICATION_NTFY_SERVER_URL }}
|
||||||
|
E2E_NOTIFICATION_NTFY_TOKEN: ${{ secrets.E2E_NOTIFICATION_NTFY_TOKEN }}
|
||||||
|
E2E_NOTIFICATION_NTFY_USERNAME: ${{ secrets.E2E_NOTIFICATION_NTFY_USERNAME }}
|
||||||
|
E2E_NOTIFICATION_NTFY_PASSWORD: ${{ secrets.E2E_NOTIFICATION_NTFY_PASSWORD }}
|
||||||
|
E2E_NOTIFICATION_WEBHOOK_URL: ${{ secrets.E2E_NOTIFICATION_WEBHOOK_URL }}
|
||||||
|
E2E_NOTIFICATION_WEBHOOK_SECRET_HEADER: ${{ secrets.E2E_NOTIFICATION_WEBHOOK_SECRET_HEADER }}
|
||||||
|
E2E_NOTIFICATION_WEBHOOK_SECRET: ${{ secrets.E2E_NOTIFICATION_WEBHOOK_SECRET }}
|
||||||
|
E2E_STORAGE_AWS_S3_ENDPOINT_URL: ${{ secrets.E2E_STORAGE_AWS_S3_ENDPOINT_URL }}
|
||||||
|
E2E_STORAGE_AWS_S3_REGION: ${{ secrets.E2E_STORAGE_AWS_S3_REGION }}
|
||||||
|
E2E_STORAGE_AWS_S3_ACCESS_KEY: ${{ secrets.E2E_STORAGE_AWS_S3_ACCESS_KEY }}
|
||||||
|
E2E_STORAGE_AWS_S3_SECRET_KEY: ${{ secrets.E2E_STORAGE_AWS_S3_SECRET_KEY }}
|
||||||
|
E2E_STORAGE_AWS_S3_BUCKET_NAME: ${{ secrets.E2E_STORAGE_AWS_S3_BUCKET_NAME }}
|
||||||
|
E2E_STORAGE_AWS_S3_PORT: ${{ secrets.E2E_STORAGE_AWS_S3_PORT }}
|
||||||
|
E2E_STORAGE_R2_ENDPOINT_URL: ${{ secrets.E2E_STORAGE_R2_ENDPOINT_URL }}
|
||||||
|
E2E_STORAGE_R2_REGION: ${{ secrets.E2E_STORAGE_R2_REGION }}
|
||||||
|
E2E_STORAGE_R2_ACCESS_KEY: ${{ secrets.E2E_STORAGE_R2_ACCESS_KEY }}
|
||||||
|
E2E_STORAGE_R2_SECRET_KEY: ${{ secrets.E2E_STORAGE_R2_SECRET_KEY }}
|
||||||
|
E2E_STORAGE_R2_BUCKET_NAME: ${{ secrets.E2E_STORAGE_R2_BUCKET_NAME }}
|
||||||
|
E2E_STORAGE_R2_PORT: ${{ secrets.E2E_STORAGE_R2_PORT }}
|
||||||
|
E2E_STORAGE_GOOGLE_DRIVE_CLIENT_ID: ${{ secrets.E2E_STORAGE_GOOGLE_DRIVE_CLIENT_ID }}
|
||||||
|
E2E_STORAGE_GOOGLE_DRIVE_CLIENT_SECRET: ${{ secrets.E2E_STORAGE_GOOGLE_DRIVE_CLIENT_SECRET }}
|
||||||
|
E2E_STORAGE_GOOGLE_DRIVE_FOLDER_ID: ${{ secrets.E2E_STORAGE_GOOGLE_DRIVE_FOLDER_ID }}
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
name: GitHub Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_call:
|
|
||||||
inputs:
|
|
||||||
prerelease:
|
|
||||||
required: false
|
|
||||||
type: boolean
|
|
||||||
default: false
|
|
||||||
make_latest:
|
|
||||||
required: false
|
|
||||||
type: boolean
|
|
||||||
default: false
|
|
||||||
discord_title:
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
discord_color:
|
|
||||||
required: true
|
|
||||||
type: number
|
|
||||||
discord_footer:
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
secrets:
|
|
||||||
DISCORD_WEBHOOK:
|
|
||||||
required: true
|
|
||||||
GH_TOKEN:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
create-release:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
steps:
|
|
||||||
- name: Check out the repo
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Build Changelog
|
|
||||||
id: build_changelog
|
|
||||||
uses: mikepenz/release-changelog-builder-action@v5
|
|
||||||
with:
|
|
||||||
mode: "COMMIT"
|
|
||||||
configurationJson: |
|
|
||||||
{
|
|
||||||
"template": "#{{CHANGELOG}}",
|
|
||||||
"categories": [
|
|
||||||
{
|
|
||||||
"title": "## Feature",
|
|
||||||
"labels": ["feat", "feature"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "## Fix",
|
|
||||||
"labels": ["fix", "bug"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "## Other",
|
|
||||||
"labels": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"label_extractor": [
|
|
||||||
{
|
|
||||||
"pattern": "^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test){1}(\([\w\-\.]+\))?(!)?: ([\w ])+([\s\S]*)",
|
|
||||||
"on_property": "title",
|
|
||||||
"target": "$1"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
|
||||||
|
|
||||||
- name: Create GitHub Release
|
|
||||||
uses: softprops/action-gh-release@v2
|
|
||||||
with:
|
|
||||||
generate_release_notes: false
|
|
||||||
body: ${{ steps.build_changelog.outputs.changelog }}
|
|
||||||
prerelease: ${{ inputs.prerelease }}
|
|
||||||
make_latest: ${{ inputs.make_latest }}
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
|
||||||
|
|
||||||
- name: Send Discord Notification
|
|
||||||
env:
|
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
|
||||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
|
||||||
run: |
|
|
||||||
RELEASE_INFO=$(gh release view "${{ github.ref_name }}" -R ${{ github.repository }} --json name,url,body,author)
|
|
||||||
|
|
||||||
RELEASE_TITLE=$(echo "$RELEASE_INFO" | jq -r .name)
|
|
||||||
if [ -z "$RELEASE_TITLE" ] || [ "$RELEASE_TITLE" = "null" ]; then RELEASE_TITLE="${{ github.ref_name }}"; fi
|
|
||||||
|
|
||||||
RELEASE_URL=$(echo "$RELEASE_INFO" | jq -r .url)
|
|
||||||
RELEASE_BODY=$(echo "$RELEASE_INFO" | jq -r .body)
|
|
||||||
|
|
||||||
AUTHOR_NAME="Portabase"
|
|
||||||
AUTHOR_ICON="https://github.com/Portabase.png"
|
|
||||||
|
|
||||||
PAYLOAD=$(jq -n \
|
|
||||||
--arg title "$RELEASE_TITLE" \
|
|
||||||
--arg description "$RELEASE_BODY" \
|
|
||||||
--arg url "$RELEASE_URL" \
|
|
||||||
--arg author "$AUTHOR_NAME" \
|
|
||||||
--arg icon "$AUTHOR_ICON" \
|
|
||||||
--arg discord_title "${{ inputs.discord_title }}" \
|
|
||||||
--arg discord_footer "${{ inputs.discord_footer }}" \
|
|
||||||
--argjson discord_color ${{ inputs.discord_color }} \
|
|
||||||
'{
|
|
||||||
content: $discord_title,
|
|
||||||
embeds: [{
|
|
||||||
title: $title,
|
|
||||||
url: $url,
|
|
||||||
description: $description,
|
|
||||||
color: $discord_color,
|
|
||||||
author: {
|
|
||||||
name: $author,
|
|
||||||
icon_url: $icon
|
|
||||||
},
|
|
||||||
footer: {
|
|
||||||
text: $discord_footer
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
}'
|
|
||||||
)
|
|
||||||
|
|
||||||
curl -H "Content-Type: application/json" \
|
|
||||||
-d "$PAYLOAD" \
|
|
||||||
"$DISCORD_WEBHOOK"
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
name: Publish Helm Chart
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
secrets:
|
||||||
|
GH_TOKEN:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish-helm:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Helm
|
||||||
|
uses: azure/setup-helm@v4
|
||||||
|
|
||||||
|
- name: Package Helm chart
|
||||||
|
run: |
|
||||||
|
mkdir -p ./helm-packages
|
||||||
|
helm package helm \
|
||||||
|
--version ${{ inputs.version }} \
|
||||||
|
--app-version ${{ inputs.version }} \
|
||||||
|
--destination ./helm-packages
|
||||||
|
|
||||||
|
- name: Authenticate to GitHub Packages
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.GH_TOKEN }}" | helm registry login ghcr.io -u ${{ github.actor }} --password-stdin
|
||||||
|
|
||||||
|
- name: Push Helm chart to GitHub Packages (OCI)
|
||||||
|
run: |
|
||||||
|
helm push ./helm-packages/portabase-${{ inputs.version }}.tgz oci://ghcr.io/portabase/charts
|
||||||
@@ -3,31 +3,28 @@ name: Publish Docker image for release candidate
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- '*-rc*'
|
- "*.*.*-rc*"
|
||||||
|
- "!*-*-rc*"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
docker_publish:
|
docker_publish:
|
||||||
uses: ./.github/workflows/docker.yml
|
uses: ./.github/workflows/docker.yml
|
||||||
with:
|
with:
|
||||||
add_latest: false
|
add_latest: true
|
||||||
secrets:
|
secrets:
|
||||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME_2 }}
|
||||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD_2 }}
|
||||||
DOCKER_USERNAME_2: ${{ secrets.DOCKER_USERNAME_2 }}
|
|
||||||
DOCKER_PASSWORD_2: ${{ secrets.DOCKER_PASSWORD_2 }}
|
|
||||||
|
|
||||||
github_release:
|
discord_notification:
|
||||||
needs: docker_publish
|
needs: docker_publish
|
||||||
uses: ./.github/workflows/github.yml
|
uses: ./.github/workflows/discord.yml
|
||||||
with:
|
with:
|
||||||
prerelease: true
|
discord_title: "New Release Candidate: ${{ github.ref_name }}"
|
||||||
make_latest: false
|
discord_color: 2044800
|
||||||
discord_title: "||@release-dashboard|| New release candidate published"
|
|
||||||
discord_color: 16776960
|
|
||||||
discord_footer: "Portabase"
|
discord_footer: "Portabase"
|
||||||
secrets:
|
secrets:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
|
|||||||
+125
-17
@@ -1,34 +1,142 @@
|
|||||||
name: Publish Docker image for release
|
name: Auto Release & Publish
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
pull_request_target:
|
||||||
tags:
|
types: [ closed ]
|
||||||
- '*.*.*'
|
branches:
|
||||||
- '!*-*'
|
- main
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
docker_publish:
|
|
||||||
|
check-skip:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
skip: ${{ steps.set-skip.outputs.skip }}
|
||||||
|
steps:
|
||||||
|
- name: Determine if release should be skipped
|
||||||
|
id: set-skip
|
||||||
|
run: |
|
||||||
|
TITLE="${{ github.event.pull_request.title }}"
|
||||||
|
echo "PR title: $TITLE"
|
||||||
|
|
||||||
|
if [[ "$TITLE" == *"[skip-release]"* ]]; then
|
||||||
|
echo "PR title contains [skip-release], skipping release jobs."
|
||||||
|
echo "skip=true" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "skip=false" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
create-release:
|
||||||
|
needs: check-skip
|
||||||
|
if: ${{ needs.check-skip.outputs.skip == 'false' && github.event.pull_request.merged == true }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
draft_tag: ${{ steps.release_step.outputs.draft_tag }}
|
||||||
|
version: ${{ steps.release_step.outputs.version }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/create-github-app-token@v1
|
||||||
|
id: app-token
|
||||||
|
with:
|
||||||
|
app-id: ${{ vars.APP_ID }}
|
||||||
|
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||||
|
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ steps.app-token.outputs.token }}
|
||||||
|
ref: main
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "lts/*"
|
||||||
|
cache: "pnpm"
|
||||||
|
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- run: |
|
||||||
|
git config --global user.name 'github-actions[bot]'
|
||||||
|
git config --global user.email 'github-actions[bot]@users.noreply.github.com'
|
||||||
|
|
||||||
|
|
||||||
|
- name: Run release-it
|
||||||
|
id: release_step
|
||||||
|
run: |
|
||||||
|
git pull origin main
|
||||||
|
|
||||||
|
VERSION=$(pnpm exec release-it --ci --release-version)
|
||||||
|
echo $VERSION
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
OUTPUT=$(pnpm exec release-it --ci)
|
||||||
|
echo "$OUTPUT"
|
||||||
|
|
||||||
|
DRAFT_TAG=$(echo "$OUTPUT" | grep -oE 'untagged-[a-z0-9]+')
|
||||||
|
echo $DRAFT_TAG
|
||||||
|
echo "draft_tag=$DRAFT_TAG" >> $GITHUB_OUTPUT
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||||
|
|
||||||
|
|
||||||
|
publish-docker:
|
||||||
|
needs: create-release
|
||||||
|
if: ${{ needs.create-release.result == 'success' }}
|
||||||
uses: ./.github/workflows/docker.yml
|
uses: ./.github/workflows/docker.yml
|
||||||
with:
|
with:
|
||||||
|
version: ${{ needs.create-release.outputs.version }}
|
||||||
|
ref: ${{ needs.create-release.outputs.version }}
|
||||||
add_latest: true
|
add_latest: true
|
||||||
secrets:
|
secrets:
|
||||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME_2 }}
|
||||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD_2 }}
|
||||||
DOCKER_USERNAME_2: ${{ secrets.DOCKER_USERNAME_2 }}
|
|
||||||
DOCKER_PASSWORD_2: ${{ secrets.DOCKER_PASSWORD_2 }}
|
|
||||||
|
|
||||||
github_release:
|
publish-helm:
|
||||||
needs: docker_publish
|
needs: create-release
|
||||||
uses: ./.github/workflows/github.yml
|
if: ${{ needs.create-release.result == 'success' }}
|
||||||
|
uses: ./.github/workflows/helm.yml
|
||||||
with:
|
with:
|
||||||
prerelease: false
|
version: ${{ needs.create-release.outputs.version }}
|
||||||
make_latest: true
|
secrets:
|
||||||
discord_title: "||@release-dashboard|| New release published"
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
discord_color: 5814783
|
|
||||||
|
finalize-release:
|
||||||
|
needs:
|
||||||
|
- create-release
|
||||||
|
- publish-docker
|
||||||
|
- publish-helm
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
release_tag: ${{ steps.publish_release_step.outputs.release_tag }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Publish GitHub Release
|
||||||
|
id: publish_release_step
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
OUTPUT=$(gh release edit ${{ needs.create-release.outputs.draft_tag }} --draft=false)
|
||||||
|
echo "$OUTPUT"
|
||||||
|
RELEASE_TAG=$(echo "$OUTPUT" | sed -E 's|.*/releases/tag/||')
|
||||||
|
echo "release_tag=$RELEASE_TAG" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
notify-discord:
|
||||||
|
needs:
|
||||||
|
- publish-docker
|
||||||
|
- publish-helm
|
||||||
|
- create-release
|
||||||
|
- finalize-release
|
||||||
|
uses: ./.github/workflows/discord.yml
|
||||||
|
with:
|
||||||
|
release_tag: ${{ needs.create-release.outputs.version }}
|
||||||
|
discord_title: "New Release"
|
||||||
|
discord_color: 3066993
|
||||||
discord_footer: "Portabase"
|
discord_footer: "Portabase"
|
||||||
secrets:
|
secrets:
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
|
|||||||
@@ -5,27 +5,27 @@ on:
|
|||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
sca-deps: # Dependency & container scan
|
sca-deps:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: aquasecurity/trivy-action@0.20.0
|
- uses: aquasecurity/trivy-action@v0.35.0
|
||||||
with:
|
with:
|
||||||
scan-type: 'fs'
|
scan-type: 'fs'
|
||||||
format: 'table'
|
format: 'table'
|
||||||
severity: 'CRITICAL,HIGH'
|
severity: 'CRITICAL,HIGH'
|
||||||
ignore-unfixed: true
|
ignore-unfixed: true
|
||||||
|
|
||||||
|
secrets-gitleaks:
|
||||||
secrets-gitleaks: # Secrets exposure
|
if: github.event.pull_request.head.repo.fork == false
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # Fetch full history for gitleaks
|
fetch-depth: 0
|
||||||
- uses: gitleaks/gitleaks-action@v2
|
- uses: gitleaks/gitleaks-action@v2
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}
|
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}
|
||||||
with:
|
with:
|
||||||
config-path: .gitleaks.toml # Optional, if you have a custom config
|
config-path: .gitleaks.toml
|
||||||
+18
-1
@@ -1,5 +1,6 @@
|
|||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
.idea
|
.idea
|
||||||
|
.vscode
|
||||||
# dependencies
|
# dependencies
|
||||||
/node_modules
|
/node_modules
|
||||||
/.pnp
|
/.pnp
|
||||||
@@ -39,7 +40,23 @@ public/uploads/*
|
|||||||
!public/uploads/
|
!public/uploads/
|
||||||
|
|
||||||
private/uploads/*
|
private/uploads/*
|
||||||
|
private/keys/*
|
||||||
|
|
||||||
/.env
|
/.env
|
||||||
|
|
||||||
certificates
|
seeds/pocket-id/*.db
|
||||||
|
|
||||||
|
certificates
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
/test-results/
|
||||||
|
/playwright-report/
|
||||||
|
/blob-report/
|
||||||
|
/playwright/.cache/
|
||||||
|
/playwright/.auth/
|
||||||
|
/e2e/local-storage.json
|
||||||
|
|
||||||
|
.claude
|
||||||
|
.codex
|
||||||
|
/docs
|
||||||
|
.worktrees
|
||||||
|
|||||||
+1
-3
@@ -2,9 +2,7 @@ title = "Custom gitleaks config"
|
|||||||
|
|
||||||
[allowlist]
|
[allowlist]
|
||||||
# Global allowlist patterns (won’t be flagged)
|
# Global allowlist patterns (won’t be flagged)
|
||||||
regexes = [
|
regexes = []
|
||||||
"s3SecretAccessKey: env.S3_SECRET_KEY ?? null",
|
|
||||||
]
|
|
||||||
paths = [
|
paths = [
|
||||||
"src/utils/init.ts"
|
"src/utils/init.ts"
|
||||||
]
|
]
|
||||||
|
|||||||
Generated
-8
@@ -1,8 +0,0 @@
|
|||||||
# Default ignored files
|
|
||||||
/shelf/
|
|
||||||
/workspace.xml
|
|
||||||
# Editor-based HTTP Client requests
|
|
||||||
/httpRequests/
|
|
||||||
# Datasource local storage ignored files
|
|
||||||
/dataSources/
|
|
||||||
/dataSources.local.xml
|
|
||||||
Generated
-8
@@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="ProjectModuleManager">
|
|
||||||
<modules>
|
|
||||||
<module fileurl="file://$PROJECT_DIR$/.idea/portabase.iml" filepath="$PROJECT_DIR$/.idea/portabase.iml" />
|
|
||||||
</modules>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
Generated
-12
@@ -1,12 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<module type="JAVA_MODULE" version="4">
|
|
||||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
|
||||||
<exclude-output />
|
|
||||||
<content url="file://$MODULE_DIR$">
|
|
||||||
<sourceFolder url="file://$MODULE_DIR$/backend" isTestSource="false" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/venv" />
|
|
||||||
</content>
|
|
||||||
<orderEntry type="inheritedJdk" />
|
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
|
||||||
</component>
|
|
||||||
</module>
|
|
||||||
Generated
-6
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="VcsDirectoryMappings">
|
|
||||||
<mapping directory="" vcs="Git" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"github": {
|
||||||
|
"release": true,
|
||||||
|
"draft": true,
|
||||||
|
"tokenRef": "GITHUB_TOKEN"
|
||||||
|
},
|
||||||
|
"git": {
|
||||||
|
"commit": true,
|
||||||
|
"commitMessage": "chore: release ${version}",
|
||||||
|
"requireCleanWorkingDir": true,
|
||||||
|
"tag": true,
|
||||||
|
"tagName": "${version}",
|
||||||
|
"push": true
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"@release-it/conventional-changelog": {
|
||||||
|
"preset": {
|
||||||
|
"name": "conventionalcommits",
|
||||||
|
"types": [
|
||||||
|
{
|
||||||
|
"type": "feat",
|
||||||
|
"section": "✨ Features"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "fix",
|
||||||
|
"section": "🐛 Bug Fixes"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "perf",
|
||||||
|
"section": "⚡️ Performance Improvements"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "revert",
|
||||||
|
"section": "⏪️ Reverts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "docs",
|
||||||
|
"section": "📝 Documentation",
|
||||||
|
"hidden": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "chore",
|
||||||
|
"section": "🔧 Chores",
|
||||||
|
"hidden": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@release-it/bumper": {
|
||||||
|
"out": {
|
||||||
|
"file": "CITATION.cff",
|
||||||
|
"path": "version",
|
||||||
|
"type": "text/yaml"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
-3
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"WillLuke.nextjs.hasPrompted": true
|
|
||||||
}
|
|
||||||
+11
-4
@@ -1,6 +1,6 @@
|
|||||||
cff-version: 1.2.0
|
cff-version: 1.2.0
|
||||||
title: Portabase
|
title: Portabase
|
||||||
message: "If you use this software, please cite it as below."
|
message: If you use this software, please cite it as below.
|
||||||
type: software
|
type: software
|
||||||
authors:
|
authors:
|
||||||
- family-names: Gauthereau
|
- family-names: Gauthereau
|
||||||
@@ -9,13 +9,20 @@ authors:
|
|||||||
given-names: Killian
|
given-names: Killian
|
||||||
repository-code: https://github.com/Portabase/portabase
|
repository-code: https://github.com/Portabase/portabase
|
||||||
url: https://portabase.io
|
url: https://portabase.io
|
||||||
abstract: "Portabase is a free, open-source, self-hosted solution for database administration, providing backup and restore capabilities, scheduling, retention policies, notifications, and support for multiple storage backends. Its headless agent architecture enables connection to multiple database instances securely and efficiently."
|
abstract: >-
|
||||||
|
Portabase is a free, open-source, self-hosted solution for database
|
||||||
|
administration, providing backup and restore capabilities, scheduling,
|
||||||
|
retention policies, notifications, and support for multiple storage backends.
|
||||||
|
Its headless agent architecture enables connection to multiple database
|
||||||
|
instances securely and efficiently.
|
||||||
keywords:
|
keywords:
|
||||||
- docker
|
- docker
|
||||||
- kubernetes
|
- kubernetes
|
||||||
- backups
|
- backups
|
||||||
- postgresql
|
- postgresql
|
||||||
- mysql
|
- mysql
|
||||||
|
- firebird
|
||||||
|
- mssql
|
||||||
- mariadb
|
- mariadb
|
||||||
- devops
|
- devops
|
||||||
- database
|
- database
|
||||||
@@ -26,5 +33,5 @@ keywords:
|
|||||||
- web-ui
|
- web-ui
|
||||||
- agent
|
- agent
|
||||||
license: Apache-2.0
|
license: Apache-2.0
|
||||||
version: 1.2.0-rc.1
|
version: 1.20.0
|
||||||
date-released: "2026-01-18"
|
date-released: '2026-03-02'
|
||||||
|
|||||||
@@ -186,7 +186,7 @@
|
|||||||
same "printed page" as the copyright notice for easier
|
same "printed page" as the copyright notice for easier
|
||||||
identification within third-party archives.
|
identification within third-party archives.
|
||||||
|
|
||||||
Copyright 2024 Soluce Technologies
|
Copyright 2024 Portabase
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
|
|||||||
@@ -1,4 +1,56 @@
|
|||||||
CLUSTER_SCRIPT=docker/entrypoints/app-dev-entrypoint.sh
|
CLUSTER_SCRIPT=docker/entrypoints/app-dev-entrypoint.sh
|
||||||
|
|
||||||
up:
|
up:
|
||||||
@bash $(CLUSTER_SCRIPT)
|
@bash $(CLUSTER_SCRIPT)
|
||||||
|
|
||||||
|
seed-keycloak:
|
||||||
|
@[ $$(ls -1 seeds/keycloak/*.json 2>/dev/null | wc -l) -gt 0 ] || (echo "No realm export found in seeds/keycloak. Add an export from Keycloak first."; exit 1)
|
||||||
|
@docker compose -f docker-compose.func.yml stop keycloak >/dev/null 2>&1 || true
|
||||||
|
@docker compose -f docker-compose.func.yml rm -f -s keycloak >/dev/null 2>&1 || true
|
||||||
|
@docker volume rm portabase-dev-func_keycloak-data >/dev/null 2>&1 || true
|
||||||
|
@docker compose -f docker-compose.func.yml up -d keycloak
|
||||||
|
@echo "Keycloak seed import triggered from seeds/keycloak/*.json"
|
||||||
|
|
||||||
|
seed-pocket:
|
||||||
|
@[ -f seeds/pocket-id/portabase.zip ] || (echo "No export found in seeds/pocket-id. Add an export from Pocket ID first."; exit 1)
|
||||||
|
@docker compose -f docker-compose.func.yml stop pocket-id >/dev/null 2>&1 || true
|
||||||
|
@docker compose -f docker-compose.func.yml rm -f -s pocket-id >/dev/null 2>&1 || true
|
||||||
|
@docker volume rm portabase-dev-func_pocket-id-data >/dev/null 2>&1 || true
|
||||||
|
@docker compose -f docker-compose.func.yml run --rm -v $$(pwd)/seeds/pocket-id/portabase.zip:/tmp/portabase.zip pocket-id ./pocket-id import --yes --path /tmp/portabase.zip >/dev/null
|
||||||
|
@docker compose -f docker-compose.func.yml up -d pocket-id
|
||||||
|
@sleep 2
|
||||||
|
@docker compose -f docker-compose.func.yml exec pocket-id ./pocket-id one-time-access-token admin
|
||||||
|
@echo "Pocket ID data restored from seeds/pocket-id/portabase.zip"
|
||||||
|
|
||||||
|
seed-auth: seed-keycloak seed-pocket
|
||||||
|
|
||||||
|
pocket-token:
|
||||||
|
@docker compose -f docker-compose.func.yml exec pocket-id ./pocket-id one-time-access-token admin
|
||||||
|
|
||||||
|
export-pocket:
|
||||||
|
@echo "Exporting Pocket ID data to seeds/pocket-id/portabase.zip..."
|
||||||
|
@mkdir -p ./seeds/pocket-id
|
||||||
|
@docker compose -f docker-compose.func.yml exec pocket-id ./pocket-id export --path /tmp/portabase-export.zip
|
||||||
|
@docker compose -f docker-compose.func.yml cp pocket-id:/tmp/portabase-export.zip ./seeds/pocket-id/portabase.zip
|
||||||
|
@docker compose -f docker-compose.func.yml exec pocket-id rm /tmp/portabase-export.zip
|
||||||
|
@echo "Pocket ID data exported to seeds/pocket-id/portabase.zip"
|
||||||
|
|
||||||
|
export-keycloak:
|
||||||
|
@echo "Exporting Keycloak configuration and users to seeds/keycloak/*.json..."
|
||||||
|
@mkdir -p ./seeds/keycloak
|
||||||
|
@rm -f ./seeds/keycloak/*.json
|
||||||
|
@docker compose -f docker-compose.func.yml stop keycloak >/dev/null 2>&1
|
||||||
|
@docker rm -f kc-exporter >/dev/null 2>&1 || true
|
||||||
|
@docker compose -f docker-compose.func.yml run --name kc-exporter keycloak export --dir /tmp/kc-export --users realm_file
|
||||||
|
@docker cp kc-exporter:/tmp/kc-export/. ./seeds/keycloak/
|
||||||
|
@docker rm -f kc-exporter >/dev/null 2>&1
|
||||||
|
@docker compose -f docker-compose.func.yml start keycloak >/dev/null 2>&1
|
||||||
|
@echo "Keycloak configuration and users exported to seeds/keycloak/*.json"
|
||||||
|
|
||||||
|
seed-blob:
|
||||||
|
@chmod +x seeds/azurite/azurite-seed.sh
|
||||||
|
@bash seeds/azurite/azurite-seed.sh
|
||||||
|
|
||||||
|
list-blob:
|
||||||
|
@chmod +x seeds/azurite/azurite-list.sh
|
||||||
|
@bash seeds/azurite/azurite-list.sh
|
||||||
@@ -7,21 +7,29 @@
|
|||||||
<h3 align="center">Portabase</h3>
|
<h3 align="center">Portabase</h3>
|
||||||
|
|
||||||
<p align="center" style="margin-top: 20px; font-style: italic;">
|
<p align="center" style="margin-top: 20px; font-style: italic;">
|
||||||
<i>Portabase is a tool designed to simplify the backup and restoration of your database instances. It integrates seamlessly with <a href="https://github.com/Portabase/agent">Portabase agents</a> for managing operations securely and efficiently.</i>
|
<i>Portabase is a tool designed to simplify the backup and restoration of your database instances. It integrates seamlessly with <a href="https://github.com/Portabase/agent-rust">Portabase agents</a> for managing operations securely and efficiently.</i>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://hub.docker.com/r/solucetechnologies/portabase)
|
[](https://hub.docker.com/r/portabase/portabase)
|
||||||
|
[](https://github.com/Portabase/portabase/pkgs/container/charts%2Fportabase)
|
||||||
[](https://github.com/Portabase/portabase)
|
[](https://github.com/Portabase/portabase)
|
||||||
|
[](https://www.buymeacoffee.com/portabase)
|
||||||
|
|
||||||
[](https://www.postgresql.org/)
|
[](https://www.postgresql.org/)
|
||||||
[](https://www.mysql.com/)
|
[](https://www.mysql.com/)
|
||||||
[](https://mariadb.org/)
|
[](https://mariadb.org/)
|
||||||
|
[](https://sqlite.org/)
|
||||||
|
[](https://redis.io/)
|
||||||
|
[](https://www.mongodb.com/)
|
||||||
|
[](https://valkey.io/)
|
||||||
|
[](https://firebirdsql.org/)
|
||||||
|
[](https://www.microsoft.com/en-us/sql-server)
|
||||||
|
|
||||||
[](https://github.com/Portabase/portabase)
|
[](https://github.com/Portabase/portabase)
|
||||||
[](https://github.com/Portabase/portabase)
|
[](https://github.com/Portabase/portabase)
|
||||||
|
|
||||||
|
|
||||||
[![NextJS][NextJS]][NextJS-url]
|
[![NextJS][NextJS]][NextJS-url]
|
||||||
[![BetterAuth][BetterAuth]][BetterAuth-url]
|
[![BetterAuth][BetterAuth]][BetterAuth-url]
|
||||||
[![Drizzle][Drizzle]][Drizzle-url]
|
[![Drizzle][Drizzle]][Drizzle-url]
|
||||||
@@ -32,35 +40,52 @@
|
|||||||
<strong>
|
<strong>
|
||||||
<a href="https://portabase.io">Website</a> •
|
<a href="https://portabase.io">Website</a> •
|
||||||
<a href="https://portabase.io/docs">Documentation</a> •
|
<a href="https://portabase.io/docs">Documentation</a> •
|
||||||
<a href="https://www.youtube.com/watch?v=hvLbX5LN1UE">Demo</a> •
|
<a href="https://www.youtube.com/watch?v=nSTzT27GgAg">Demo</a> •
|
||||||
<a href="https://portabase.io/docs/dashboard/setup">Installation</a> •
|
<a href="https://portabase.io/docs/dashboard/setup">Installation</a> •
|
||||||
<a href="https://github.com/Portabase/portabase/issues/new?labels=bug&template=bug-report---.md">Report Bug</a> •
|
<a href="https://github.com/Portabase/portabase/issues/new?labels=bug&template=bug-report---.md">Report Bug</a> •
|
||||||
<a href="https://github.com/Portabase/portabase/issues/new?labels=enhancement&template=feature-request---.md">Request Feature</a>
|
<a href="https://github.com/Portabase/portabase/issues/new?labels=enhancement&template=feature-request---.md">Request Feature</a>
|
||||||
</strong>
|
</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
You have 4 ways to install Portabase:
|
You have 5 ways to install Portabase:
|
||||||
|
|
||||||
- Automated CLI (recommended) - [details](https://portabase.io/docs/dashboard/setup#cli)
|
- Automated CLI (recommended) - [details](https://portabase.io/docs/dashboard/setup#cli)
|
||||||
- Docker Compose setup - [details](https://portabase.io/docs/dashboard/setup#docker)
|
- Docker Run - [details](https://portabase.io/docs/dashboard/setup#docker)
|
||||||
- Kubernetes with Helm (soon)
|
- Docker Compose setup - [details](https://portabase.io/docs/dashboard/setup#docker-compose)
|
||||||
|
- Unraid Community Apps - [details](https://ca.unraid.net/apps/portabase-dashboard-1sdc97m05ufd7q) - [unraid support thread](https://forums.unraid.net/topic/199072-support-portabase-dashboard-agent-dockers/)
|
||||||
|
- Kubernetes with Helm - [details](https://portabase.io/docs/dashboard/setup#helm)
|
||||||
- Development setup - [details](https://portabase.io/docs/dashboard/setup#development)
|
- Development setup - [details](https://portabase.io/docs/dashboard/setup#development)
|
||||||
|
|
||||||
**Ensure Docker is installed on your machine before getting started.**
|
**Ensure Docker is installed on your machine before getting started.**
|
||||||
|
|
||||||
|
## Supported databases
|
||||||
|
|
||||||
|
| Engine | Support | Supported Versions | Restore |
|
||||||
|
|:-------------------|:----------|:------------------------------|:--------|
|
||||||
|
| **PostgreSQL** | ✅ Stable | 12, 13, 14, 15, 16, 17 and 18 | Yes |
|
||||||
|
| **MySQL** | ✅ Stable | 5.7, 8 and 9 | Yes |
|
||||||
|
| **MariaDB** | ✅ Stable | 10 and 11 | Yes |
|
||||||
|
| **MongoDB** | ✅ Stable | 4, 5, 6, 7 and 8 | Yes |
|
||||||
|
| **SQLite** | ✅ Stable | 3.x | Yes |
|
||||||
|
| **Redis** | ✅ Stable | 2.8+ | No |
|
||||||
|
| **Valkey** | ✅ Stable | 7.2+ | No |
|
||||||
|
| **Firebird** | ✅ Stable | 3.0, 4.0, 5.0 | Yes |
|
||||||
|
| **MSSQL Server** | ✅ Stable | 2017, 2019, 2022, Azure SQL | Yes |
|
||||||
|
|
||||||
|
See the [Database Servers documentation](https://portabase.io/docs/agent/db) for version-specific backup and restore details.
|
||||||
|
|
||||||
## Contributors
|
## Contributors
|
||||||
|
|
||||||
[](https://github.com/Portabase/portabase/graphs/contributors)
|
[](https://github.com/Portabase/portabase/graphs/contributors)
|
||||||
|
|
||||||
|
[](https://www.buymeacoffee.com/portabase)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
@@ -86,4 +111,3 @@ Distributed under the Apache License. See `LICENSE.txt` for more details.
|
|||||||
[ShadcnUI-url]: https://ui.shadcn.com/
|
[ShadcnUI-url]: https://ui.shadcn.com/
|
||||||
|
|
||||||
[Docker-url]: https://www.docker.com/
|
[Docker-url]: https://www.docker.com/
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import {CardContent, CardHeader} from "@/components/ui/card";
|
import { CardContent, CardHeader } from "@/components/ui/card";
|
||||||
|
|
||||||
import {TooltipProvider} from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import {ForgotPasswordForm} from "@/components/wrappers/auth/login/forgot-password-form/forgot-password-form";
|
import { ForgotPasswordForm } from "@/features/auth/forgot-password-form";
|
||||||
import {CardAuth} from "@/features/layout/card-auth";
|
import { env } from "@/env.mjs";
|
||||||
|
import { CardAuth } from "@/features/layout/card-auth";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default async function RoutePage(props: { searchParams: Promise<{ callbackUrl: string | undefined }> }) {
|
export default async function RoutePage(props: { searchParams: Promise<{ callbackUrl: string | undefined }> }) {
|
||||||
|
if (env.AUTH_EMAIL_PASSWORD_ENABLED !== "true") {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
@@ -12,12 +17,11 @@ export default async function RoutePage(props: { searchParams: Promise<{ callbac
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="grid gap-2 text-center mb-2">
|
<div className="grid gap-2 text-center mb-2">
|
||||||
<h1 className="text-3xl font-bold">Reset password</h1>
|
<h1 className="text-3xl font-bold">Reset password</h1>
|
||||||
<p className="text-balance text-muted-foreground">Enter your email address and we'll send you a
|
<p className="text-balance text-muted-foreground">Enter your email address and we'll send you a link to reset your password.</p>
|
||||||
link to reset your password.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ForgotPasswordForm/>
|
<ForgotPasswordForm />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</CardAuth>
|
</CardAuth>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {CardContent, CardHeader} from "@/components/ui/card";
|
import {CardContent, CardHeader} from "@/components/ui/card";
|
||||||
import {TooltipProvider} from "@/components/ui/tooltip";
|
import {TooltipProvider} from "@/components/ui/tooltip";
|
||||||
import {GuardForm} from "@/components/wrappers/auth/guard/guard-form";
|
import {GuardForm} from "@/features/auth/guard-form";
|
||||||
import {cookies} from "next/headers";
|
import {cookies} from "next/headers";
|
||||||
import {redirect} from "next/navigation";
|
import {redirect} from "next/navigation";
|
||||||
import {CardAuth} from "@/features/layout/card-auth";
|
import {CardAuth} from "@/features/layout/card-auth";
|
||||||
@@ -16,7 +16,7 @@ export default async function GuardPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<CardAuth className="w-full max-w-md">
|
<CardAuth className="w-full">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="grid gap-2 text-center mb-2">
|
<div className="grid gap-2 text-center mb-2">
|
||||||
<h1 className="text-3xl font-bold">Two-factor verification</h1>
|
<h1 className="text-3xl font-bold">Two-factor verification</h1>
|
||||||
|
|||||||
+30
-20
@@ -1,25 +1,35 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import {redirect} from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import {currentUser} from "@/lib/auth/current-user";
|
import { currentUser } from "@/lib/auth/current-user";
|
||||||
import {AuthLogoSection} from "@/components/wrappers/auth/auth-logo-section";
|
import { AuthLogoSection } from "@/features/auth/auth-logo-section";
|
||||||
|
import { Heart } from "lucide-react";
|
||||||
|
|
||||||
export default async function Layout({children}: { children: React.ReactNode }) {
|
export default async function Layout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const user = await currentUser();
|
||||||
|
|
||||||
const user = await currentUser();
|
if (user && !user.banned && user.role !== "pending") {
|
||||||
|
redirect("/dashboard/home");
|
||||||
|
}
|
||||||
|
|
||||||
if (user && !user.banned && user.role !== "pending") {
|
return (
|
||||||
redirect("/dashboard/home");
|
<div className="flex min-h-screen flex-col justify-between py-10 sm:px-6 lg:px-8 ">
|
||||||
}
|
<div className="flex flex-col items-center justify-center flex-1">
|
||||||
|
<div className="mx-auto w-full max-w-md">
|
||||||
return (
|
<AuthLogoSection />
|
||||||
<div className="flex min-h-full flex-1 flex-col justify-center py-12 sm:px-6 lg:px-8 ">
|
<div className="mt-4">{children}</div>
|
||||||
<div className="mx-auto w-full max-w-md">
|
|
||||||
<AuthLogoSection/>
|
|
||||||
<div>{children}</div>
|
|
||||||
</div>
|
|
||||||
<footer className="py-4 text-center text-xs justify-items-end text-muted-foreground">
|
|
||||||
Powered by <span className="font-medium">Soluce Technologies</span>
|
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
</div>
|
||||||
}
|
<footer className="mt-8 text-center text-xs text-muted-foreground flex flex-col gap-1">
|
||||||
|
<p className="flex items-center justify-center gap-1">
|
||||||
|
Made with{" "}
|
||||||
|
<Heart className="size-3 fill-red-500 text-red-500 animate-pulse" />{" "}
|
||||||
|
by <span className="font-medium text-foreground">Portabase</span>
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+49
-42
@@ -1,52 +1,59 @@
|
|||||||
import {LoginForm} from "@/components/wrappers/auth/login/login-form/login-form";
|
import { LoginForm } from "@/features/auth/login-form";
|
||||||
import {Metadata} from "next";
|
import { Metadata } from "next";
|
||||||
import {SUPPORTED_PROVIDERS} from "../../../portabase.config";
|
import { SUPPORTED_PROVIDERS } from "@/lib/auth/config";
|
||||||
import {SocialAuthButtons} from "@/components/wrappers/auth/social-buttons";
|
import { SocialAuthButtons } from "@/features/auth/social-buttons";
|
||||||
import {TooltipProvider} from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import {CardContent, CardHeader} from "@/components/ui/card";
|
import { CardContent, CardHeader } from "@/components/ui/card";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {Separator} from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import {CardAuth} from "@/features/layout/card-auth";
|
import { CardAuth } from "@/features/layout/card-auth";
|
||||||
|
import { env } from "@/env.mjs";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Login",
|
title: "Login",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function SignInPage() {
|
export default async function SignInPage() {
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<CardAuth className="w-full">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="grid gap-2 text-center mb-2">
|
||||||
|
<h1 className="text-3xl font-bold">Login</h1>
|
||||||
|
<p className="text-balance text-muted-foreground">
|
||||||
|
Fill your login informations
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{env.AUTH_EMAIL_PASSWORD_ENABLED === "true" && (
|
||||||
|
<LoginForm isPasskeyEnabled={env.AUTH_PASSKEY_ENABLED === "true"} />
|
||||||
|
)}
|
||||||
|
|
||||||
return (
|
{env.AUTH_EMAIL_PASSWORD_ENABLED === "true" &&
|
||||||
<TooltipProvider>
|
SUPPORTED_PROVIDERS.filter((p) => !p.isManual && p.isActive)
|
||||||
<CardAuth className="w-full">
|
.length > 0 && (
|
||||||
<CardHeader>
|
<div className="relative my-4 flex items-center justify-center overflow-hidden">
|
||||||
<div className="grid gap-2 text-center mb-2">
|
<Separator />
|
||||||
<h1 className="text-3xl font-bold">Login</h1>
|
<div className="px-2 text-center text-sm">OR</div>
|
||||||
<p className="text-balance text-muted-foreground">Fill your login informations</p>
|
<Separator />
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
)}
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<LoginForm/>
|
|
||||||
|
|
||||||
{SUPPORTED_PROVIDERS.filter((p) => !p.isManual && p.isActive).length > 0 && (
|
{SUPPORTED_PROVIDERS.filter((p) => !p.isManual && p.isActive).length >
|
||||||
<>
|
0 && <SocialAuthButtons providers={SUPPORTED_PROVIDERS} />}
|
||||||
<div className="relative my-4 flex items-center justify-center overflow-hidden">
|
|
||||||
<Separator/>
|
|
||||||
<div className="px-2 text-center text-sm">OR</div>
|
|
||||||
<Separator/>
|
|
||||||
</div>
|
|
||||||
<SocialAuthButtons providers={SUPPORTED_PROVIDERS}/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-4 text-center text-sm">
|
{env.AUTH_SIGNUP_ENABLED !== "true" ||
|
||||||
Don't have an account ?{" "}
|
(env.AUTH_EMAIL_PASSWORD_ENABLED === "true" && (
|
||||||
<Link href="/register" className="underline">
|
<div className="mt-4 text-center text-sm">
|
||||||
Sign up
|
Don't have an account ?{" "}
|
||||||
</Link>
|
<Link href="/register" className="underline">
|
||||||
</div>
|
Sign up
|
||||||
</CardContent>
|
</Link>
|
||||||
</CardAuth>
|
</div>
|
||||||
</TooltipProvider>
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</CardAuth>
|
||||||
)
|
</TooltipProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
import {PageParams} from "@/types/next";
|
import { PageParams } from "@/types/next";
|
||||||
import {RegisterForm} from "@/components/wrappers/auth/register/register-form/register-form";
|
import { RegisterForm } from "@/features/auth/register-form";
|
||||||
import {Metadata} from "next";
|
import { Metadata } from "next";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { env } from "@/env.mjs";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Register",
|
title: "Register",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{}>) {
|
export default async function RoutePage(props: PageParams<{}>) {
|
||||||
return (
|
if (
|
||||||
<div className="mx-auto grid w-full gap-6">
|
env.AUTH_SIGNUP_ENABLED !== "true" ||
|
||||||
<RegisterForm/>
|
env.AUTH_EMAIL_PASSWORD_ENABLED !== "true"
|
||||||
</div>
|
) {
|
||||||
)
|
redirect("/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto grid w-full gap-6">
|
||||||
|
<RegisterForm />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
import { CardContent, CardHeader } from "@/components/ui/card";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { ResetPasswordForm } from "@/components/wrappers/auth/login/reset-password-form/reset-password-form";
|
import { ResetPasswordForm } from "@/features/auth/reset-password-form";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { auth } from "@/lib/auth/auth";
|
import { auth } from "@/lib/auth/auth";
|
||||||
import { Avatar, AvatarImage, AvatarFallback } from "@radix-ui/react-avatar";
|
import { Avatar, AvatarImage, AvatarFallback } from "@radix-ui/react-avatar";
|
||||||
|
import { CardAuth } from "@/features/layout/card-auth";
|
||||||
|
import { env } from "@/env.mjs";
|
||||||
|
|
||||||
export default async function RoutePage(props: { searchParams: Promise<{ token: string | undefined }> }) {
|
export default async function RoutePage(props: { searchParams: Promise<{ token: string | undefined }> }) {
|
||||||
|
if (env.AUTH_EMAIL_PASSWORD_ENABLED !== "true") {
|
||||||
|
return redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
const { token } = await props.searchParams;
|
const { token } = await props.searchParams;
|
||||||
|
|
||||||
@@ -23,7 +28,7 @@ export default async function RoutePage(props: { searchParams: Promise<{ token:
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Card className="w-full max-w-md shadow-lg">
|
<CardAuth className="w-full">
|
||||||
<CardHeader className="space-y-4">
|
<CardHeader className="space-y-4">
|
||||||
<div className="space-y-1 text-center">
|
<div className="space-y-1 text-center">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Set a new password</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Set a new password</h1>
|
||||||
@@ -53,7 +58,7 @@ export default async function RoutePage(props: { searchParams: Promise<{ token:
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<ResetPasswordForm />
|
<ResetPasswordForm />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</CardAuth>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {db} from "@/db";
|
|||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {PageParams} from "@/types/next";
|
import {PageParams} from "@/types/next";
|
||||||
import {Page} from "@/features/layout/page";
|
import {Page} from "@/features/layout/page";
|
||||||
import {OrganizationManagement} from "@/components/wrappers/dashboard/admin/organizations/organization/organization-management";
|
import {OrganizationManagement} from "@/features/organizations/admin-organization-management";
|
||||||
import {buildOrganizationWithMembers} from "@/utils/common";
|
import {buildOrganizationWithMembers} from "@/utils/common";
|
||||||
import {isUUID} from "@/utils/text";
|
import {isUUID} from "@/utils/text";
|
||||||
import {user} from "@/db/schema/02_user";
|
import {user} from "@/db/schema/02_user";
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/
|
|||||||
import {db} from "@/db";
|
import {db} from "@/db";
|
||||||
import {
|
import {
|
||||||
AdminOrganizationAddModal
|
AdminOrganizationAddModal
|
||||||
} from "@/components/wrappers/dashboard/admin/organizations/organization/admin-organization-add-modal";
|
} from "@/features/organizations/admin-organization-add-modal";
|
||||||
import {AdminOrganizationList} from "@/components/wrappers/dashboard/admin/organizations/organization/admin-orgnization-list";
|
import {AdminOrganizationList} from "@/features/organizations/admin-organization-list";
|
||||||
import {isNull} from "drizzle-orm";
|
import {isNull} from "drizzle-orm";
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{}>) {
|
export default async function RoutePage(props: PageParams<{}>) {
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
import {PageParams} from "@/types/next";
|
|
||||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
|
||||||
import {db} from "@/db";
|
|
||||||
import {SettingsEmailSection} from "@/components/wrappers/dashboard/admin/settings/email/settings-email-section";
|
|
||||||
import {notFound} from "next/navigation";
|
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{}>) {
|
|
||||||
|
|
||||||
const settings = await db.query.setting.findFirst({
|
|
||||||
where: (fields, {eq}) => eq(fields.name, "system"),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!settings) {
|
|
||||||
notFound()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Page>
|
|
||||||
<PageHeader className="flex flex-col">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<PageTitle className="mb-3">System email</PageTitle>
|
|
||||||
</div>
|
|
||||||
</PageHeader>
|
|
||||||
<PageContent className="flex flex-col gap-5">
|
|
||||||
<SettingsEmailSection settings={settings}/>
|
|
||||||
</PageContent>
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import {PageParams} from "@/types/next";
|
|
||||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
|
||||||
import {SettingsStorageSection} from "@/components/wrappers/dashboard/admin/settings/storage/settings-storage-section";
|
|
||||||
import {db} from "@/db";
|
|
||||||
import {notFound} from "next/navigation";
|
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{}>) {
|
|
||||||
|
|
||||||
const settings = await db.query.setting.findFirst({
|
|
||||||
where: (fields, {eq}) => eq(fields.name, "system"),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!settings) {
|
|
||||||
notFound()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Page>
|
|
||||||
<PageHeader className="flex flex-col">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<PageTitle className="mb-3">System storage</PageTitle>
|
|
||||||
</div>
|
|
||||||
</PageHeader>
|
|
||||||
<PageContent className="flex flex-col gap-5">
|
|
||||||
<SettingsStorageSection settings={settings}/>
|
|
||||||
</PageContent>
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
import {PageParams} from "@/types/next";
|
import {PageParams} from "@/types/next";
|
||||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||||
import {db} from "@/db";
|
import {db} from "@/db";
|
||||||
|
import {logger} from "@/lib/logger";
|
||||||
import {notFound} from "next/navigation";
|
import {notFound} from "next/navigation";
|
||||||
import {SettingsTabs} from "@/components/wrappers/dashboard/admin/settings/settings-tabs";
|
import {SettingsTabs} from "@/features/settings/settings-tabs";
|
||||||
import {desc, isNull} from "drizzle-orm";
|
import {desc, isNull} from "drizzle-orm";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
|
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
|
||||||
|
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
|
||||||
|
|
||||||
|
const log = logger.child({module: "dashboard/admin/settings"});
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{}>) {
|
export default async function RoutePage(props: PageParams<{}>) {
|
||||||
|
|
||||||
@@ -13,6 +17,8 @@ export default async function RoutePage(props: PageParams<{}>) {
|
|||||||
where: (fields, {eq}) => eq(fields.name, "system"),
|
where: (fields, {eq}) => eq(fields.name, "system"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
log.debug({settings}, "Settings loaded");
|
||||||
|
|
||||||
const storageChannels = await db.query.storageChannel.findMany({
|
const storageChannels = await db.query.storageChannel.findMany({
|
||||||
with: {
|
with: {
|
||||||
organizations: true
|
organizations: true
|
||||||
@@ -21,7 +27,16 @@ export default async function RoutePage(props: PageParams<{}>) {
|
|||||||
orderBy: desc(drizzleDb.schemas.storageChannel.createdAt)
|
orderBy: desc(drizzleDb.schemas.storageChannel.createdAt)
|
||||||
}) as StorageChannelWith[]
|
}) as StorageChannelWith[]
|
||||||
|
|
||||||
if (!settings || !storageChannels ) {
|
const notificationChannels = await db.query.notificationChannel.findMany({
|
||||||
|
with: {
|
||||||
|
organizations: true
|
||||||
|
},
|
||||||
|
where: isNull(drizzleDb.schemas.notificationChannel.organizationId),
|
||||||
|
orderBy: desc(drizzleDb.schemas.notificationChannel.createdAt)
|
||||||
|
}) as NotificationChannelWith[]
|
||||||
|
|
||||||
|
|
||||||
|
if (!settings || !storageChannels || !notificationChannels) {
|
||||||
notFound()
|
notFound()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +48,8 @@ export default async function RoutePage(props: PageParams<{}>) {
|
|||||||
</div>
|
</div>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<PageContent className="flex flex-col gap-5">
|
<PageContent className="flex flex-col gap-5">
|
||||||
<SettingsTabs storageChannels={storageChannels} settings={settings} />
|
<SettingsTabs storageChannels={storageChannels} notificationChannels={notificationChannels}
|
||||||
|
settings={settings}/>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import {PageParams} from "@/types/next";
|
|||||||
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||||
import {db} from "@/db";
|
import {db} from "@/db";
|
||||||
import {desc, isNull} from "drizzle-orm";
|
import {desc, isNull} from "drizzle-orm";
|
||||||
import {AdminUserList} from "@/components/wrappers/dashboard/admin/users/admin-user-list";
|
import {AdminUserList} from "@/features/users/admin-user-list";
|
||||||
import {AdminUserAddModal} from "@/components/wrappers/dashboard/admin/users/admin-user-add-modal";
|
import {AdminUserAddModal} from "@/features/users/admin-user-add-modal";
|
||||||
|
import {SUPPORTED_PROVIDERS} from "@/lib/auth/config";
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{}>) {
|
export default async function RoutePage(props: PageParams<{}>) {
|
||||||
|
|
||||||
@@ -21,18 +22,23 @@ export default async function RoutePage(props: PageParams<{}>) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const credentialProvider = SUPPORTED_PROVIDERS.find(p => p.id === 'credential');
|
||||||
|
const isPasswordAuthEnabled = credentialProvider?.isActive || false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<PageHeader className="flex flex-col">
|
<PageHeader className="flex flex-col">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<PageTitle className="mb-3">Active users</PageTitle>
|
<PageTitle className="mb-3">Active users</PageTitle>
|
||||||
<PageActions>
|
{isPasswordAuthEnabled && (
|
||||||
<AdminUserAddModal organizations={organizations}/>
|
<PageActions>
|
||||||
</PageActions>
|
<AdminUserAddModal organizations={organizations}/>
|
||||||
|
</PageActions>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<PageContent className="flex flex-col gap-5">
|
<PageContent className="flex flex-col gap-5">
|
||||||
<AdminUserList users={users}/>
|
<AdminUserList users={users} isPasswordAuthEnabled={isPasswordAuthEnabled}/>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import { PageParams } from "@/types/next";
|
|
||||||
import { Page, PageContent, PageHeader, PageTitle } from "@/features/layout/page";
|
|
||||||
import { AgentForm } from "@/components/wrappers/dashboard/agent/agent-form/agent-form";
|
|
||||||
import { notFound } from "next/navigation";
|
|
||||||
import { db } from "@/db";
|
|
||||||
|
|
||||||
export default async function RoutePage(
|
|
||||||
props: PageParams<{
|
|
||||||
agentId: string;
|
|
||||||
}>
|
|
||||||
) {
|
|
||||||
const { agentId } = await props.params;
|
|
||||||
|
|
||||||
const agent = await db.query.agent.findFirst({
|
|
||||||
where: (fields, { eq }) => eq(fields.id, agentId),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!agent) {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Page>
|
|
||||||
<PageHeader>
|
|
||||||
<PageTitle>Edit {agent.name}</PageTitle>
|
|
||||||
</PageHeader>
|
|
||||||
<PageContent>
|
|
||||||
<AgentForm defaultValues={agent} agentId={agent.id} />
|
|
||||||
</PageContent>
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,65 +1,88 @@
|
|||||||
import Link from "next/link";
|
import { PageParams } from "@/types/next";
|
||||||
import {GearIcon} from "@radix-ui/react-icons";
|
import {
|
||||||
import {PageParams} from "@/types/next";
|
Page,
|
||||||
import {Page, PageActions, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
|
PageContent,
|
||||||
import {buttonVariants} from "@/components/ui/button";
|
PageDescription,
|
||||||
import {db} from "@/db";
|
PageTitle,
|
||||||
|
} from "@/features/layout/page";
|
||||||
|
import { db } from "@/db";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {eq} from "drizzle-orm";
|
import {eq, isNull} from "drizzle-orm";
|
||||||
import {notFound} from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import {ButtonDeleteAgent} from "@/components/wrappers/dashboard/agent/button-delete-agent/button-delete-agent";
|
import { ButtonDeleteAgent } from "@/features/agents/agent-delete-button";
|
||||||
import {capitalizeFirstLetter} from "@/utils/text";
|
import { capitalizeFirstLetter } from "@/utils/text";
|
||||||
import {generateEdgeKey} from "@/utils/edge_key";
|
import { generateEdgeKey } from "@/utils/edge_key";
|
||||||
import {getServerUrl} from "@/utils/get-server-url";
|
import { getServerUrl } from "@/utils/get-server-url";
|
||||||
import {AgentContentPage} from "@/components/wrappers/dashboard/agent/agent-content";
|
import { AgentContentPage } from "@/features/agents/agent-content";
|
||||||
|
import { AgentDialog } from "@/features/agents/agent-dialog";
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{ agentId: string }>) {
|
|
||||||
|
|
||||||
const {agentId} = await props.params
|
export default async function RoutePage(
|
||||||
|
props: PageParams<{ agentId: string }>,
|
||||||
|
) {
|
||||||
|
const { agentId } = await props.params;
|
||||||
|
|
||||||
const agent = await db.query.agent.findFirst({
|
const agent = await db.query.agent.findFirst({
|
||||||
where: eq(drizzleDb.schemas.agent.id, agentId),
|
where: eq(drizzleDb.schemas.agent.id, agentId),
|
||||||
with: {
|
with: {
|
||||||
databases: true
|
databases: true,
|
||||||
}
|
organizations: true,
|
||||||
})
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (!agent) {
|
const organizations = await db.query.organization.findMany({
|
||||||
notFound()
|
where: (fields) => isNull(fields.deletedAt),
|
||||||
}
|
with: {
|
||||||
|
members: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const edgeKey = await generateEdgeKey(getServerUrl(), agent.id);
|
|
||||||
|
|
||||||
return (
|
if (!agent) {
|
||||||
<Page>
|
notFound();
|
||||||
<div className="justify-between gap-2 sm:flex">
|
}
|
||||||
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
|
||||||
<div className="min-w-full md:min-w-fit ">
|
const isOwnerByAnOrganization = agent.organizationId
|
||||||
{capitalizeFirstLetter(agent.name)}
|
|
||||||
</div>
|
if (isOwnerByAnOrganization){
|
||||||
<div className="flex items-center gap-2 md:justify-between w-full ">
|
notFound();
|
||||||
<div className="flex items-center gap-2">
|
}
|
||||||
<Link className={buttonVariants({variant: "outline"})}
|
|
||||||
href={`/dashboard/agents/${agent.id}/edit`}>
|
const organizationIds = agent.organizations.map(org => org.organizationId)
|
||||||
<GearIcon className="w-7 h-7"/>
|
|
||||||
</Link>
|
const edgeKey = await generateEdgeKey(getServerUrl(), agent.id);
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
return (
|
||||||
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"}/>
|
<Page>
|
||||||
</div>
|
<div className="justify-between gap-2 sm:flex">
|
||||||
</div>
|
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
||||||
</PageTitle>
|
<div className="min-w-full md:min-w-fit ">
|
||||||
|
{capitalizeFirstLetter(agent.name)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 md:justify-between w-full ">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AgentDialog
|
||||||
|
agent={agent}
|
||||||
|
typeTrigger={"edit"}
|
||||||
|
adminView={true}
|
||||||
|
organizations={organizations}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ButtonDeleteAgent organizationIds={organizationIds} agentId={agentId} text={"Delete Agent"} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageTitle>
|
||||||
|
</div>
|
||||||
|
|
||||||
{agent.description && (
|
{agent.description && (
|
||||||
<PageDescription className="mt-5 sm:mt-0">{agent.description}</PageDescription>
|
<PageDescription className="mt-5 sm:mt-0">
|
||||||
)}
|
{agent.description}
|
||||||
<PageContent className="flex flex-col w-full h-full justify-between gap-6">
|
</PageDescription>
|
||||||
<AgentContentPage
|
)}
|
||||||
agent={agent}
|
<PageContent className="flex flex-col w-full h-full justify-between gap-6">
|
||||||
edgeKey={edgeKey}
|
<AgentContentPage agent={agent} edgeKey={edgeKey} />
|
||||||
/>
|
</PageContent>
|
||||||
</PageContent>
|
</Page>
|
||||||
</Page>
|
);
|
||||||
)
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
import {PageParams} from "@/types/next";
|
|
||||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
|
||||||
import {AgentForm} from "@/components/wrappers/dashboard/agent/agent-form/agent-form";
|
|
||||||
import {Metadata} from "next";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Create Agent",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{}>) {
|
|
||||||
return (
|
|
||||||
<Page>
|
|
||||||
<PageHeader>
|
|
||||||
<PageTitle>Create new agent</PageTitle>
|
|
||||||
</PageHeader>
|
|
||||||
<PageContent>
|
|
||||||
<AgentForm/>
|
|
||||||
</PageContent>
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,13 @@
|
|||||||
import {PageParams} from "@/types/next";
|
import {PageParams} from "@/types/next";
|
||||||
import {AgentCard} from "@/components/wrappers/dashboard/agent/agent-card/agent-card";
|
import {AgentCard} from "@/features/agents/agent-card";
|
||||||
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
|
import {CardsWithPagination} from "@/components/common/cards-with-pagination";
|
||||||
import {Button} from "@/components/ui/button";
|
|
||||||
import Link from "next/link";
|
|
||||||
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||||
import {notFound} from "next/navigation";
|
import {notFound} from "next/navigation";
|
||||||
import {db} from "@/db";
|
import {db} from "@/db";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {desc, eq, not} from "drizzle-orm";
|
import {and, desc, eq, isNull, not} from "drizzle-orm";
|
||||||
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
|
|
||||||
import {Metadata} from "next";
|
import {Metadata} from "next";
|
||||||
|
import {AgentDialog} from "@/features/agents/agent-dialog";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Agents",
|
title: "Agents",
|
||||||
@@ -18,14 +16,13 @@ export const metadata: Metadata = {
|
|||||||
export default async function RoutePage(props: PageParams<{}>) {
|
export default async function RoutePage(props: PageParams<{}>) {
|
||||||
|
|
||||||
const agents = await db.query.agent.findMany({
|
const agents = await db.query.agent.findMany({
|
||||||
where: not(eq(drizzleDb.schemas.agent.isArchived, true)),
|
where: and(not(eq(drizzleDb.schemas.agent.isArchived, true)),isNull(drizzleDb.schemas.agent.organizationId)),
|
||||||
with: {
|
with: {
|
||||||
databases: true
|
databases: true
|
||||||
},
|
},
|
||||||
orderBy: (fields) => desc(fields.lastContact),
|
orderBy: (fields) => desc(fields.lastContact),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
if (!agents) {
|
if (!agents) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
@@ -36,9 +33,7 @@ export default async function RoutePage(props: PageParams<{}>) {
|
|||||||
<PageTitle>Agents</PageTitle>
|
<PageTitle>Agents</PageTitle>
|
||||||
{agents.length > 0 && (
|
{agents.length > 0 && (
|
||||||
<PageActions>
|
<PageActions>
|
||||||
<Link href={"/dashboard/agents/new"}>
|
<AgentDialog typeTrigger={"create"} />
|
||||||
<Button>+ Create Agent</Button>
|
|
||||||
</Link>
|
|
||||||
</PageActions>
|
</PageActions>
|
||||||
)}
|
)}
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
@@ -46,12 +41,9 @@ export default async function RoutePage(props: PageParams<{}>) {
|
|||||||
{agents.length > 0 ? (
|
{agents.length > 0 ? (
|
||||||
<CardsWithPagination data={agents} cardItem={AgentCard} cardsPerPage={4} numberOfColumns={1}/>
|
<CardsWithPagination data={agents} cardItem={AgentCard} cardsPerPage={4} numberOfColumns={1}/>
|
||||||
) : (
|
) : (
|
||||||
<EmptyStatePlaceholder
|
<AgentDialog typeTrigger="empty"/>
|
||||||
url={"/dashboard/agents/new"}
|
|
||||||
text={"Create new Agent"}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</PageContent>
|
</PageContent>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4,8 +4,8 @@ import {Metadata} from "next";
|
|||||||
import {db} from "@/db";
|
import {db} from "@/db";
|
||||||
import {notificationChannel, NotificationChannelWith} from "@/db/schema/09_notification-channel";
|
import {notificationChannel, NotificationChannelWith} from "@/db/schema/09_notification-channel";
|
||||||
import {desc, isNull} from "drizzle-orm";
|
import {desc, isNull} from "drizzle-orm";
|
||||||
import {ChannelsSection} from "@/components/wrappers/dashboard/admin/channels/channels-section";
|
import {ChannelsSection} from "@/features/channel/channels-section";
|
||||||
import {ChannelAddEditModal} from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal";
|
import {ChannelAddEditModal} from "@/features/channel/channel-add-edit-modal";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -35,12 +35,10 @@ export default async function RoutePage(props: PageParams<{}>) {
|
|||||||
<PageHeader>
|
<PageHeader>
|
||||||
<PageTitle>Notification channels</PageTitle>
|
<PageTitle>Notification channels</PageTitle>
|
||||||
<PageActions>
|
<PageActions>
|
||||||
{/*<NotifierAddEditModal adminView={false}/>*/}
|
|
||||||
<ChannelAddEditModal kind="notification" adminView={false}/>
|
<ChannelAddEditModal kind="notification" adminView={false}/>
|
||||||
</PageActions>
|
</PageActions>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<PageContent>
|
<PageContent>
|
||||||
{/*<NotificationChannelsSection organizations={organizations} notificationChannels={notificationChannels}/>*/}
|
|
||||||
<ChannelsSection kind="notification" organizations={organizations}
|
<ChannelsSection kind="notification" organizations={organizations}
|
||||||
channels={notificationChannels}/>
|
channels={notificationChannels}/>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {PageParams} from "@/types/next";
|
import {PageParams} from "@/types/next";
|
||||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||||
import {Metadata} from "next";
|
import {Metadata} from "next";
|
||||||
import {NotificationLogsList} from "@/components/wrappers/dashboard/admin/notifications/logs/notification-logs-list";
|
import {NotificationLogsList} from "@/features/notifications/notification-logs-list";
|
||||||
import {notFound} from "next/navigation";
|
import {notFound} from "next/navigation";
|
||||||
import {getNotificationHistory} from "@/db/services/notification-log";
|
import {getNotificationHistory} from "@/db/services/notification-log";
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import {PageParams} from "@/types/next";
|
import {PageParams} from "@/types/next";
|
||||||
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||||
import {Metadata} from "next";
|
import {Metadata} from "next";
|
||||||
import {ChannelsSection} from "@/components/wrappers/dashboard/admin/channels/channels-section";
|
import {ChannelsSection} from "@/features/channel/channels-section";
|
||||||
import {db} from "@/db";
|
import {db} from "@/db";
|
||||||
import {desc, eq, isNull} from "drizzle-orm";
|
import {desc, eq, isNull} from "drizzle-orm";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
|
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
|
||||||
import {ChannelAddEditModal} from "@/components/wrappers/dashboard/admin/channels/channel/channel-add-edit-modal";
|
import {ChannelAddEditModal} from "@/features/channel/channel-add-edit-modal";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Storage Channels",
|
title: "Storage Channels",
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import {PageParams} from "@/types/next";
|
||||||
|
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||||
|
import {notFound} from "next/navigation";
|
||||||
|
import {getOrganization} from "@/lib/auth/auth";
|
||||||
|
import {Metadata} from "next";
|
||||||
|
import {db} from "@/db";
|
||||||
|
import {MigrationTool} from "@/features/migration/migration-tool";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Projects",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function RoutePage(props: PageParams<{}>) {
|
||||||
|
|
||||||
|
const organization = await getOrganization({});
|
||||||
|
|
||||||
|
if (!organization) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const projects = await db.query.project.findMany({
|
||||||
|
where: (project, {eq, and, not}) =>
|
||||||
|
and(
|
||||||
|
eq(project.organizationId, organization.id),
|
||||||
|
not(eq(project.isArchived, true))
|
||||||
|
),
|
||||||
|
with: {
|
||||||
|
organization: true,
|
||||||
|
databases: {
|
||||||
|
where: (database, { isNull, not, inArray, and }) =>
|
||||||
|
and(
|
||||||
|
isNull(database.deletedAt),
|
||||||
|
not(inArray(database.dbms, ["valkey", "redis"]))
|
||||||
|
),
|
||||||
|
|
||||||
|
with: {
|
||||||
|
backups: {
|
||||||
|
where: (backup, { isNull, eq, and }) =>
|
||||||
|
and(
|
||||||
|
isNull(backup.deletedAt),
|
||||||
|
eq(backup.status, "success")
|
||||||
|
),
|
||||||
|
orderBy: (backup, {desc}) => [desc(backup.createdAt)],
|
||||||
|
limit: 15,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<PageHeader className="flex flex-col items-start justify-between mb-6">
|
||||||
|
<PageTitle className="mb-2">
|
||||||
|
Database Migration
|
||||||
|
</PageTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Import backups from a source project into your target database
|
||||||
|
</p>
|
||||||
|
</PageHeader>
|
||||||
|
<PageContent>
|
||||||
|
<MigrationTool projects={projects}/>
|
||||||
|
</PageContent>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
-36
@@ -1,36 +0,0 @@
|
|||||||
import { PageParams } from "@/types/next";
|
|
||||||
import { Page, PageContent, PageHeader, PageTitle } from "@/features/layout/page";
|
|
||||||
import { notFound } from "next/navigation";
|
|
||||||
import { DatabaseForm } from "@/components/wrappers/dashboard/database/database-form/database-form";
|
|
||||||
|
|
||||||
import { db } from "@/db";
|
|
||||||
import * as drizzleDb from "@/db";
|
|
||||||
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{ databaseId: string }>) {
|
|
||||||
const { databaseId } = await props.params;
|
|
||||||
|
|
||||||
const dbItem = await db.query.database.findFirst({
|
|
||||||
where: eq(drizzleDb.schemas.database.id, databaseId),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!dbItem) {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Page>
|
|
||||||
<PageHeader>
|
|
||||||
<PageTitle>Edit {dbItem.name}</PageTitle>
|
|
||||||
</PageHeader>
|
|
||||||
<PageContent>
|
|
||||||
<DatabaseForm
|
|
||||||
databaseId={databaseId}
|
|
||||||
// @ts-ignore
|
|
||||||
defaultValues={{ ...dbItem, dbms: dbItem.dbms ?? "inactive", description: dbItem.description ?? undefined }}
|
|
||||||
/>
|
|
||||||
</PageContent>
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+23
-66
@@ -1,23 +1,15 @@
|
|||||||
import {PageParams} from "@/types/next";
|
import {PageParams} from "@/types/next";
|
||||||
import {notFound, redirect} from "next/navigation";
|
import {notFound, redirect} from "next/navigation";
|
||||||
import {Page, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
|
import {Page} from "@/features/layout/page";
|
||||||
import {BackupButton} from "@/components/wrappers/dashboard/backup/backup-button/backup-button";
|
|
||||||
import {DatabaseKpi} from "@/components/wrappers/dashboard/projects/database/database-kpi";
|
|
||||||
import {CronButton} from "@/components/wrappers/dashboard/database/cron-button/cron-button";
|
|
||||||
import {db} from "@/db";
|
import {db} from "@/db";
|
||||||
import {eq, and, inArray} from "drizzle-orm";
|
import {eq, and, inArray} from "drizzle-orm";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {getOrganizationProjectDatabases} from "@/lib/services";
|
import {getOrganizationProjectDatabases} from "@/db/services/project";
|
||||||
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
||||||
import {RetentionPolicySheet} from "@/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet";
|
import {BackupModalProvider} from "@/features/database/backup-modal-context";
|
||||||
import {capitalizeFirstLetter} from "@/utils/text";
|
import {DatabaseContent} from "@/features/database/database-content";
|
||||||
import {getOrganizationChannels} from "@/db/services/notification-channel";
|
import {getHealthLast12hLogs} from "@/db/services/healthcheck";
|
||||||
import {ImportModal} from "@/components/wrappers/dashboard/database/import/import-modal";
|
import {LogsModalProvider} from "@/features/logs/logs-modal-context";
|
||||||
import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
|
|
||||||
import {ChannelPoliciesModal} from "@/components/wrappers/dashboard/database/channels-policy/policy-modal";
|
|
||||||
import {HardDrive, Megaphone} from "lucide-react";
|
|
||||||
import {BackupModalProvider} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
|
|
||||||
import {DatabaseContent} from "@/components/wrappers/dashboard/projects/database/database-content";
|
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{
|
export default async function RoutePage(props: PageParams<{
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -43,7 +35,7 @@ export default async function RoutePage(props: PageParams<{
|
|||||||
project: true,
|
project: true,
|
||||||
retentionPolicy: true,
|
retentionPolicy: true,
|
||||||
alertPolicies: true,
|
alertPolicies: true,
|
||||||
storagePolicies: true
|
storagePolicies: true,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -59,13 +51,17 @@ export default async function RoutePage(props: PageParams<{
|
|||||||
with: {
|
with: {
|
||||||
storageChannel: true
|
storageChannel: true
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
logs: true
|
||||||
},
|
},
|
||||||
orderBy: (b, {desc}) => [desc(b.createdAt)],
|
orderBy: (b, {desc}) => [desc(b.createdAt)],
|
||||||
});
|
});
|
||||||
|
|
||||||
const restorations = await db.query.restoration.findMany({
|
const restorations = await db.query.restoration.findMany({
|
||||||
where: eq(drizzleDb.schemas.restoration.databaseId, dbItem.id),
|
where: eq(drizzleDb.schemas.restoration.databaseId, dbItem.id),
|
||||||
|
with: {
|
||||||
|
logs: true
|
||||||
|
},
|
||||||
orderBy: (r, {desc}) => [desc(r.createdAt)],
|
orderBy: (r, {desc}) => [desc(r.createdAt)],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -93,11 +89,7 @@ export default async function RoutePage(props: PageParams<{
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const organizationChannels = await getOrganizationChannels(organization.id);
|
const databaseHealthLogs = dbItem ? await getHealthLast12hLogs({id: dbItem.id}) : []
|
||||||
const activeOrganizationChannels = organizationChannels.filter(channel => channel.enabled);
|
|
||||||
|
|
||||||
const organizationStorageChannels = await getOrganizationStorageChannels(organization.id);
|
|
||||||
const activeOrganizationStorageChannels = organizationStorageChannels.filter(channel => channel.enabled);
|
|
||||||
|
|
||||||
|
|
||||||
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
|
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
|
||||||
@@ -106,60 +98,25 @@ export default async function RoutePage(props: PageParams<{
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
|
<LogsModalProvider>
|
||||||
<div className="justify-between gap-2 sm:flex">
|
|
||||||
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
|
||||||
<div className="min-w-full md:min-w-fit ">
|
|
||||||
{capitalizeFirstLetter(dbItem.name)}
|
|
||||||
</div>
|
|
||||||
{!isMember && (
|
|
||||||
<div className="flex items-center gap-2 md:justify-between w-full ">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<RetentionPolicySheet database={dbItem}/>
|
|
||||||
<CronButton database={dbItem}/>
|
|
||||||
<ChannelPoliciesModal
|
|
||||||
database={dbItem}
|
|
||||||
kind={"notification"}
|
|
||||||
icon={<Megaphone/>}
|
|
||||||
channels={activeOrganizationChannels}
|
|
||||||
organizationId={organization.id}
|
|
||||||
/>
|
|
||||||
<ChannelPoliciesModal
|
|
||||||
database={dbItem}
|
|
||||||
icon={<HardDrive/>}
|
|
||||||
kind={"storage"}
|
|
||||||
channels={activeOrganizationStorageChannels}
|
|
||||||
organizationId={organization.id}
|
|
||||||
/>
|
|
||||||
<ImportModal database={dbItem}/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<BackupButton disable={isAlreadyBackup} databaseId={databaseId}/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</PageTitle>
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{dbItem.description && (
|
|
||||||
<PageDescription className="mt-5 sm:mt-0">{dbItem.description}</PageDescription>
|
|
||||||
)}
|
|
||||||
<PageContent className="flex flex-col w-full h-full">
|
|
||||||
<DatabaseKpi successRate={successRate} database={dbItem} availableBackups={availableBackups}
|
|
||||||
totalBackups={totalBackups}/>
|
|
||||||
<BackupModalProvider>
|
<BackupModalProvider>
|
||||||
<DatabaseContent
|
<DatabaseContent
|
||||||
activeMember={activeMember}
|
activeMember={activeMember}
|
||||||
settings={settings}
|
settings={settings}
|
||||||
database={dbItem}
|
database={dbItem}
|
||||||
|
databaseHealthLogs={databaseHealthLogs}
|
||||||
isAlreadyRestore={isAlreadyRestore}
|
isAlreadyRestore={isAlreadyRestore}
|
||||||
restorations={restorations}
|
restorations={restorations}
|
||||||
backups={backups}
|
backups={backups}
|
||||||
|
totalBackups={totalBackups}
|
||||||
|
availableBackups={availableBackups}
|
||||||
|
successRate={successRate}
|
||||||
|
organizationId={organization.id}
|
||||||
|
activeOrganizationChannels={[]}
|
||||||
|
activeOrganizationStorageChannels={[]}
|
||||||
/>
|
/>
|
||||||
</BackupModalProvider>
|
</BackupModalProvider>
|
||||||
</PageContent>
|
</LogsModalProvider>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import {notFound} from "next/navigation";
|
|
||||||
import {PageParams} from "@/types/next";
|
|
||||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
|
||||||
import {ProjectForm} from "@/components/wrappers/dashboard/projects/project-form/project-form";
|
|
||||||
import * as drizzleDb from "@/db";
|
|
||||||
|
|
||||||
import {db} from "@/db";
|
|
||||||
import {eq} from "drizzle-orm";
|
|
||||||
import {DatabaseWith} from "@/db/schema/07_database";
|
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{ projectId: string }>) {
|
|
||||||
const {projectId} = await props.params;
|
|
||||||
|
|
||||||
const proj = await db.query.project.findFirst({
|
|
||||||
where: eq(drizzleDb.schemas.project.id, projectId),
|
|
||||||
with: {
|
|
||||||
databases: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!proj) {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const org = await db.query.organization.findFirst({
|
|
||||||
where: eq(drizzleDb.schemas.organization.slug, "default"),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!org) {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
const availableDatabases = (
|
|
||||||
await db.query.database.findMany({
|
|
||||||
where: (db, {or, eq, isNull}) => or(isNull(db.projectId), eq(db.projectId, proj.id)),
|
|
||||||
with: {
|
|
||||||
agent: true,
|
|
||||||
project: true,
|
|
||||||
backups: true,
|
|
||||||
restorations: true,
|
|
||||||
},
|
|
||||||
orderBy: (db, {desc}) => [desc(db.createdAt)],
|
|
||||||
})
|
|
||||||
)
|
|
||||||
// .filter((db) => db.project !== null) as DatabaseWith[];
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Page>
|
|
||||||
<PageHeader>
|
|
||||||
<PageTitle>Edit {proj.name}</PageTitle>
|
|
||||||
</PageHeader>
|
|
||||||
<PageContent>
|
|
||||||
<ProjectForm
|
|
||||||
organization={org}
|
|
||||||
databases={availableDatabases as DatabaseWith[]}
|
|
||||||
defaultValues={{...proj, databases: proj.databases.map((db) => db.id)}}
|
|
||||||
projectId={proj.id}
|
|
||||||
/>
|
|
||||||
</PageContent>
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,20 +1,20 @@
|
|||||||
import {PageParams} from "@/types/next";
|
import {PageParams} from "@/types/next";
|
||||||
import {Page, PageActions, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
|
import {Page, PageContent, PageTitle} from "@/features/layout/page";
|
||||||
import {buttonVariants} from "@/components/ui/button";
|
|
||||||
import {GearIcon} from "@radix-ui/react-icons";
|
|
||||||
import Link from "next/link";
|
|
||||||
import {
|
import {
|
||||||
ButtonDeleteProject
|
ButtonDeleteProject
|
||||||
} from "@/components/wrappers/dashboard/projects/button-delete-project/button-delete-project";
|
} from "@/features/projects/project-delete-button";
|
||||||
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
|
import {CardsWithPagination} from "@/components/common/cards-with-pagination";
|
||||||
import {ProjectDatabaseCard} from "@/components/wrappers/dashboard/projects/project-card/project-database-card";
|
import {ProjectDatabaseCard} from "@/features/projects/project-database-card";
|
||||||
import {notFound, redirect} from "next/navigation";
|
import {notFound, redirect} from "next/navigation";
|
||||||
|
|
||||||
import {db} from "@/db";
|
import {db} from "@/db";
|
||||||
import {eq} from "drizzle-orm";
|
import {eq} from "drizzle-orm";
|
||||||
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {capitalizeFirstLetter} from "@/utils/text";
|
import {capitalizeFirstLetter} from "@/utils/text";
|
||||||
|
import {ProjectDialog} from "@/features/projects/project-dialog";
|
||||||
|
import {ProjectWith} from "@/db/schema/06_project";
|
||||||
|
import {isUuidv4} from "@/utils/verify-uuid";
|
||||||
|
import {getOrganizationAvailableDatabases} from "@/db/services/database";
|
||||||
|
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{
|
export default async function RoutePage(props: PageParams<{
|
||||||
@@ -24,6 +24,10 @@ export default async function RoutePage(props: PageParams<{
|
|||||||
projectId
|
projectId
|
||||||
} = await props.params;
|
} = await props.params;
|
||||||
|
|
||||||
|
if (!isUuidv4(projectId)) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
const organization = await getOrganization({});
|
const organization = await getOrganization({});
|
||||||
const activeMember = await getActiveMember()
|
const activeMember = await getActiveMember()
|
||||||
|
|
||||||
@@ -51,6 +55,7 @@ export default async function RoutePage(props: PageParams<{
|
|||||||
redirect("/dashboard/projects");
|
redirect("/dashboard/projects");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const availableDatabases = await getOrganizationAvailableDatabases(organization.id, proj.id)
|
||||||
const isMember = activeMember?.role === "member";
|
const isMember = activeMember?.role === "member";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -63,10 +68,12 @@ export default async function RoutePage(props: PageParams<{
|
|||||||
{!isMember && (
|
{!isMember && (
|
||||||
<div className="flex items-center gap-2 md:justify-between w-full ">
|
<div className="flex items-center gap-2 md:justify-between w-full ">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Link className={buttonVariants({variant: "outline"})}
|
<ProjectDialog
|
||||||
href={`/dashboard/projects/${proj.id}/edit`}>
|
databases={availableDatabases}
|
||||||
<GearIcon className="w-7 h-7"/>
|
organization={org}
|
||||||
</Link>
|
project={proj as ProjectWith}
|
||||||
|
isEdit={true}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ButtonDeleteProject projectId={projectId} text={"Delete Project"}/>
|
<ButtonDeleteProject projectId={projectId} text={"Delete Project"}/>
|
||||||
@@ -78,12 +85,15 @@ export default async function RoutePage(props: PageParams<{
|
|||||||
<PageContent className="flex flex-col w-full h-full">
|
<PageContent className="flex flex-col w-full h-full">
|
||||||
{proj.databases.length > 0 ? (
|
{proj.databases.length > 0 ? (
|
||||||
<CardsWithPagination
|
<CardsWithPagination
|
||||||
data={proj.databases}
|
data={[...proj.databases].sort((a, b) =>
|
||||||
|
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||||
|
)}
|
||||||
organizationSlug={organization.slug}
|
organizationSlug={organization.slug}
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
cardItem={ProjectDatabaseCard}
|
cardItem={ProjectDatabaseCard}
|
||||||
cardsPerPage={6}
|
cardsPerPage={20}
|
||||||
numberOfColumns={3}
|
numberOfColumns={3}
|
||||||
|
pageSizeOptions={[10, 20, 50]}
|
||||||
extendedProps={proj}
|
extendedProps={proj}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -95,4 +105,4 @@ export default async function RoutePage(props: PageParams<{
|
|||||||
</PageContent>
|
</PageContent>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import {PageParams} from "@/types/next";
|
|
||||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
|
||||||
import {ProjectForm} from "@/components/wrappers/dashboard/projects/project-form/project-form";
|
|
||||||
import {notFound} from "next/navigation";
|
|
||||||
import {db} from "@/db";
|
|
||||||
import {eq} from "drizzle-orm";
|
|
||||||
import {getOrganization} from "@/lib/auth/auth";
|
|
||||||
import * as drizzleDb from "@/db";
|
|
||||||
import {DatabaseWith} from "@/db/schema/07_database";
|
|
||||||
|
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{}>) {
|
|
||||||
|
|
||||||
const organization = await getOrganization({});
|
|
||||||
|
|
||||||
if (!organization) {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
const availableDatabases = (
|
|
||||||
await db.query.database.findMany({
|
|
||||||
where: (db, {isNull}) => isNull(db.projectId),
|
|
||||||
with: {
|
|
||||||
agent: true,
|
|
||||||
project: true,
|
|
||||||
backups: true,
|
|
||||||
restorations: true,
|
|
||||||
},
|
|
||||||
orderBy: (db, {desc}) => [desc(db.createdAt)],
|
|
||||||
})
|
|
||||||
).filter((db) => db.project == null) as DatabaseWith[];
|
|
||||||
|
|
||||||
const org = await db.query.organization.findFirst({
|
|
||||||
where: eq(drizzleDb.schemas.organization.slug, organization.slug),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!org) notFound();
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Page>
|
|
||||||
<PageHeader>
|
|
||||||
<PageTitle>Create new project</PageTitle>
|
|
||||||
</PageHeader>
|
|
||||||
<PageContent>
|
|
||||||
<ProjectForm databases={availableDatabases} organization={org}/>
|
|
||||||
</PageContent>
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
import {PageParams} from "@/types/next";
|
import {PageParams} from "@/types/next";
|
||||||
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
|
import {CardsWithPagination} from "@/components/common/cards-with-pagination";
|
||||||
import {Button} from "@/components/ui/button";
|
|
||||||
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||||
import {ProjectCard} from "@/components/wrappers/dashboard/projects/project-card/project-card";
|
import {ProjectCard} from "@/features/projects/project-card";
|
||||||
import {db} from "@/db";
|
import {db} from "@/db";
|
||||||
import {notFound} from "next/navigation";
|
import {notFound} from "next/navigation";
|
||||||
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
||||||
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
|
import {EmptyStatePlaceholder} from "@/components/common/empty-state-placeholder";
|
||||||
import {Metadata} from "next";
|
import {Metadata} from "next";
|
||||||
|
import {ProjectDialog} from "@/features/projects/project-dialog";
|
||||||
|
import {DatabaseWith} from "@/db/schema/07_database";
|
||||||
|
import {getOrganizationAvailableDatabases} from "@/db/services/database";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Projects",
|
title: "Projects",
|
||||||
@@ -36,6 +36,8 @@ export default async function RoutePage(props: PageParams<{}>) {
|
|||||||
});
|
});
|
||||||
const isMember = activeMember?.role === "member";
|
const isMember = activeMember?.role === "member";
|
||||||
|
|
||||||
|
const availableDatabases = await getOrganizationAvailableDatabases(organization.id)
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
@@ -43,9 +45,7 @@ export default async function RoutePage(props: PageParams<{}>) {
|
|||||||
<PageTitle>Projects</PageTitle>
|
<PageTitle>Projects</PageTitle>
|
||||||
{(projects.length > 0 && !isMember) && (
|
{(projects.length > 0 && !isMember) && (
|
||||||
<PageActions>
|
<PageActions>
|
||||||
<Link href={`/dashboard/projects/new`}>
|
<ProjectDialog databases={availableDatabases} organization={organization}/>
|
||||||
<Button>+ Create Project</Button>
|
|
||||||
</Link>
|
|
||||||
</PageActions>
|
</PageActions>
|
||||||
)}
|
)}
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
@@ -56,15 +56,16 @@ export default async function RoutePage(props: PageParams<{}>) {
|
|||||||
organizationSlug={organization.slug}
|
organizationSlug={organization.slug}
|
||||||
data={projects}
|
data={projects}
|
||||||
cardItem={ProjectCard}
|
cardItem={ProjectCard}
|
||||||
cardsPerPage={6}
|
cardsPerPage={12}
|
||||||
numberOfColumns={3}
|
numberOfColumns={3}
|
||||||
|
pageSizeOptions={[12, 24, 48]}
|
||||||
/>
|
/>
|
||||||
) : isMember ? (
|
) : isMember ? (
|
||||||
<EmptyStatePlaceholder text="No project available"/>
|
<EmptyStatePlaceholder state={"empty"} text="No project available"/>
|
||||||
) : (
|
) : (
|
||||||
<EmptyStatePlaceholder url="/dashboard/projects/new" text="Create new Project"/>
|
<ProjectDialog databases={availableDatabases} organization={organization} isEmpty={true}/>
|
||||||
)}
|
)}
|
||||||
</PageContent>
|
</PageContent>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import {PageParams} from "@/types/next";
|
||||||
|
import {
|
||||||
|
Page,
|
||||||
|
PageContent,
|
||||||
|
PageDescription,
|
||||||
|
PageTitle,
|
||||||
|
} from "@/features/layout/page";
|
||||||
|
import {db} from "@/db";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
import {eq} from "drizzle-orm";
|
||||||
|
import {notFound} from "next/navigation";
|
||||||
|
import {ButtonDeleteAgent} from "@/features/agents/agent-delete-button";
|
||||||
|
import {capitalizeFirstLetter} from "@/utils/text";
|
||||||
|
import {generateEdgeKey} from "@/utils/edge_key";
|
||||||
|
import {getServerUrl} from "@/utils/get-server-url";
|
||||||
|
import {AgentContentPage} from "@/features/agents/agent-content";
|
||||||
|
import {AgentDialog} from "@/features/agents/agent-dialog";
|
||||||
|
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
||||||
|
import {currentUser} from "@/lib/auth/current-user";
|
||||||
|
import {computeOrganizationPermissions} from "@/lib/acl/organization-acl";
|
||||||
|
|
||||||
|
|
||||||
|
export default async function RoutePage(
|
||||||
|
props: PageParams<{ agentId: string }>,
|
||||||
|
) {
|
||||||
|
const {agentId} = await props.params;
|
||||||
|
|
||||||
|
const organization = await getOrganization({});
|
||||||
|
const user = await currentUser();
|
||||||
|
const activeMember = await getActiveMember();
|
||||||
|
|
||||||
|
if (!organization || !activeMember || !user) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const {canManageAgents} = computeOrganizationPermissions(activeMember);
|
||||||
|
|
||||||
|
if (!canManageAgents){
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const agent = await db.query.agent.findFirst({
|
||||||
|
where: eq(drizzleDb.schemas.agent.id, agentId),
|
||||||
|
with: {
|
||||||
|
databases: true,
|
||||||
|
organizations: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
if (!agent) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAccess =
|
||||||
|
agent.organizationId === organization.id ||
|
||||||
|
agent.organizations.some(org => org.organizationId === organization.id);
|
||||||
|
|
||||||
|
if (!hasAccess) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOwned = agent.organizationId
|
||||||
|
const edgeKey = await generateEdgeKey(getServerUrl(), agent.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<div className="justify-between gap-2 sm:flex">
|
||||||
|
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
||||||
|
<div className="min-w-full md:min-w-fit ">
|
||||||
|
{capitalizeFirstLetter(agent.name)}
|
||||||
|
</div>
|
||||||
|
{isOwned && (
|
||||||
|
<div className="flex items-center gap-2 md:justify-between w-full ">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AgentDialog
|
||||||
|
agent={agent}
|
||||||
|
typeTrigger={"edit"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ButtonDeleteAgent organizationId={organization.id ?? null} agentId={agentId} text={"Delete Agent"}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PageTitle>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{agent.description && (
|
||||||
|
<PageDescription className="mt-5 sm:mt-0">
|
||||||
|
{agent.description}
|
||||||
|
</PageDescription>
|
||||||
|
)}
|
||||||
|
<PageContent className="flex flex-col w-full h-full justify-between gap-6">
|
||||||
|
<AgentContentPage agent={agent} edgeKey={edgeKey}/>
|
||||||
|
</PageContent>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default async function RoutePage() {
|
||||||
|
redirect("/dashboard/settings?tab=agents");
|
||||||
|
}
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import {PageParams} from "@/types/next";
|
|
||||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
|
||||||
import {notFound} from "next/navigation";
|
|
||||||
import {OrganizationForm} from "@/components/wrappers/dashboard/organization/organization-form/organization-form";
|
|
||||||
import {getOrganization} from "@/lib/auth/auth";
|
|
||||||
import {db} from "@/db";
|
|
||||||
import {isNull} from "drizzle-orm";
|
|
||||||
import {currentUser} from "@/lib/auth/current-user";
|
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{
|
|
||||||
|
|
||||||
}>) {
|
|
||||||
const user = await currentUser();
|
|
||||||
|
|
||||||
const organization = await getOrganization({});
|
|
||||||
|
|
||||||
const users = await db.query.user.findMany({
|
|
||||||
where: (fields) => isNull(fields.deletedAt)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user || !users || !organization || organization.slug == "default") {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
return(
|
|
||||||
<Page>
|
|
||||||
<PageHeader>
|
|
||||||
<PageTitle>
|
|
||||||
Edit {organization.name}
|
|
||||||
</PageTitle>
|
|
||||||
</PageHeader>
|
|
||||||
<PageContent>
|
|
||||||
<OrganizationForm currentUser={user} users={users} defaultValues={organization}/>
|
|
||||||
</PageContent>
|
|
||||||
</Page>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,17 +1,26 @@
|
|||||||
import {PageParams} from "@/types/next";
|
import {PageParams} from "@/types/next";
|
||||||
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
import {
|
||||||
|
Page,
|
||||||
|
PageContent,
|
||||||
|
PageHeader,
|
||||||
|
PageTitle,
|
||||||
|
} from "@/features/layout/page";
|
||||||
import {currentUser} from "@/lib/auth/current-user";
|
import {currentUser} from "@/lib/auth/current-user";
|
||||||
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
||||||
import {notFound} from "next/navigation";
|
import {notFound} from "next/navigation";
|
||||||
import {Metadata} from "next";
|
import {Metadata} from "next";
|
||||||
import {OrganizationTabs} from "@/components/wrappers/dashboard/organization/tabs/organization-tabs";
|
import {OrganizationTabs} from "@/features/organizations/organization-tabs";
|
||||||
import {getOrganizationChannels} from "@/db/services/notification-channel";
|
import {getOrganizationChannels} from "@/db/services/notification-channel";
|
||||||
import {computeOrganizationPermissions} from "@/lib/acl/organization-acl";
|
import {computeOrganizationPermissions} from "@/lib/acl/organization-acl";
|
||||||
import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
|
import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
|
||||||
import {DeleteOrganizationButton} from "@/components/wrappers/dashboard/organization/delete-organization-button";
|
import {DeleteOrganizationButton} from "@/features/organizations/organization-delete-button";
|
||||||
import {
|
import {EditOrganizationDialog} from "@/features/organizations/organization-edit-dialog";
|
||||||
EditButtonSettings
|
import {db} from "@/db";
|
||||||
} from "@/components/wrappers/dashboard/organization/settings/edit-button-settings/edit-button-settings";
|
import {isNull} from "drizzle-orm";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
import {eq} from "drizzle-orm";
|
||||||
|
import {getOrganizationAgents} from "@/db/services/agent";
|
||||||
|
import {Tooltip, TooltipContent, TooltipTrigger} from "@/components/ui/tooltip";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
@@ -20,36 +29,75 @@ export const metadata: Metadata = {
|
|||||||
export default async function RoutePage(props: PageParams<{ slug: string }>) {
|
export default async function RoutePage(props: PageParams<{ slug: string }>) {
|
||||||
const organization = await getOrganization({});
|
const organization = await getOrganization({});
|
||||||
const user = await currentUser();
|
const user = await currentUser();
|
||||||
const activeMember = await getActiveMember()
|
const activeMember = await getActiveMember();
|
||||||
|
|
||||||
if (!organization || !activeMember) {
|
if (!organization || !activeMember || !user) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const notificationChannels = await getOrganizationChannels(organization.id)
|
const notificationChannels = await getOrganizationChannels(organization.id);
|
||||||
const storageChannels = await getOrganizationStorageChannels(organization.id)
|
const storageChannels = await getOrganizationStorageChannels(organization.id);
|
||||||
|
const agents = await getOrganizationAgents(organization.id);
|
||||||
const permissions = computeOrganizationPermissions(activeMember);
|
const permissions = computeOrganizationPermissions(activeMember);
|
||||||
|
|
||||||
|
const users = await db.query.user.findMany({
|
||||||
|
where: (fields) => isNull(fields.deletedAt),
|
||||||
|
});
|
||||||
|
|
||||||
|
const organizationWithMembers = await db.query.organization.findFirst({
|
||||||
|
where: eq(drizzleDb.schemas.organization.id, organization.id),
|
||||||
|
with: {
|
||||||
|
projects: true,
|
||||||
|
members: {
|
||||||
|
with: {
|
||||||
|
user: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!organizationWithMembers) notFound();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<PageHeader>
|
<PageHeader>
|
||||||
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
||||||
<div className="min-w-full md:min-w-fit ">
|
<div className="min-w-full md:min-w-fit ">Organization settings</div>
|
||||||
Organization settings
|
<div className="flex items-center gap-2 md:justify-between w-full ">
|
||||||
</div>
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2 md:justify-between w-full ">
|
{permissions.canManageSettings &&
|
||||||
<div className="flex items-center gap-2">
|
organization.slug !== "default" && (
|
||||||
{permissions.canManageSettings && organization.slug !== "default" && (
|
<EditOrganizationDialog
|
||||||
<EditButtonSettings/>
|
organization={organizationWithMembers}
|
||||||
|
users={users}
|
||||||
|
currentUser={user}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{permissions.canManageDangerZone && organization.slug !== "default" && (
|
|
||||||
<DeleteOrganizationButton organizationSlug={organization.slug}/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{permissions.canManageDangerZone &&
|
||||||
|
organization.slug !== "default" && (
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div>
|
||||||
|
<DeleteOrganizationButton
|
||||||
|
disabled={organizationWithMembers.projects.length > 0}
|
||||||
|
organizationSlug={organization.slug}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</TooltipTrigger>
|
||||||
|
{organizationWithMembers.projects.length > 0 && (
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Your organization has some projects associated with it. Please delete them before deleting the organization.</p>
|
||||||
|
</TooltipContent>
|
||||||
|
)}
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</PageTitle>
|
</PageTitle>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<PageContent>
|
<PageContent>
|
||||||
@@ -58,8 +106,9 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
|
|||||||
organization={organization}
|
organization={organization}
|
||||||
notificationChannels={notificationChannels}
|
notificationChannels={notificationChannels}
|
||||||
storageChannels={storageChannels}
|
storageChannels={storageChannels}
|
||||||
|
agents={agents}
|
||||||
/>
|
/>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
</Page>
|
</Page>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import {PageParams} from "@/types/next";
|
import {PageParams} from "@/types/next";
|
||||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||||
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
|
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
|
||||||
import {EvolutionLineChart} from "@/components/wrappers/dashboard/statistics/charts/evolution-line-chart";
|
import {EvolutionLineChart} from "@/features/statistics/evolution-line-chart";
|
||||||
import {PercentageLineChart} from "@/components/wrappers/dashboard/statistics/charts/percentage-line-chart";
|
import {PercentageLineChart} from "@/features/statistics/percentage-line-chart";
|
||||||
import {notFound} from "next/navigation";
|
import {notFound} from "next/navigation";
|
||||||
import {db} from "@/db";
|
import {db} from "@/db";
|
||||||
import {and, asc, count, eq, inArray} from "drizzle-orm";
|
import {and, asc, count, eq, inArray} from "drizzle-orm";
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {Building2, Database, DatabaseBackup, Folder, RefreshCcw, Server, Workflo
|
|||||||
import {currentUser} from "@/lib/auth/current-user";
|
import {currentUser} from "@/lib/auth/current-user";
|
||||||
import {notFound} from "next/navigation";
|
import {notFound} from "next/navigation";
|
||||||
import {db} from "@/db";
|
import {db} from "@/db";
|
||||||
import {asc, inArray} from "drizzle-orm";
|
import {and, asc, eq, inArray} from "drizzle-orm";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {listOrganizations} from "@/lib/auth/auth";
|
import {listOrganizations} from "@/lib/auth/auth";
|
||||||
import {Metadata} from "next";
|
import {Metadata} from "next";
|
||||||
@@ -26,7 +26,7 @@ export default async function RoutePage(props: PageParams<{}>) {
|
|||||||
const agents = await db.query.agent.findMany({});
|
const agents = await db.query.agent.findMany({});
|
||||||
|
|
||||||
const projects = await db.query.project.findMany({
|
const projects = await db.query.project.findMany({
|
||||||
where: inArray(drizzleDb.schemas.project.organizationId, organizationIds),
|
where: and(inArray(drizzleDb.schemas.project.organizationId, organizationIds), eq(drizzleDb.schemas.project.isArchived, false)),
|
||||||
});
|
});
|
||||||
|
|
||||||
const projectIds = projects.map(project => project.id);
|
const projectIds = projects.map(project => project.id);
|
||||||
|
|||||||
@@ -1,27 +1,28 @@
|
|||||||
import React, {ReactNode} from "react";
|
import { ReactNode } from "react";
|
||||||
import {redirect} from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
import {SidebarInset, SidebarProvider} from "@/components/ui/sidebar";
|
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||||
import {AppSidebar} from "@/components/wrappers/dashboard/common/sidebar/app-sidebar";
|
import { AppSidebar } from "@/features/layout/app-sidebar";
|
||||||
import {Header} from "@/features/layout/Header";
|
import { Header } from "@/features/layout/header";
|
||||||
import {currentUser} from "@/lib/auth/current-user";
|
import { currentUser } from "@/lib/auth/current-user";
|
||||||
import {ThemeMetaUpdater} from "@/features/browser/theme-meta-updater";
|
import { ThemeMetaUpdater } from "@/features/theme/theme-meta-updater";
|
||||||
import {BackupModalProvider} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
|
import { ModeToggle } from "@/features/theme/mode-toggle";
|
||||||
|
import { UpdateNotification } from "@/features/updates/update-notification";
|
||||||
|
|
||||||
export default async function Layout({children}: { children: ReactNode }) {
|
export default async function Layout({ children }: { children: ReactNode }) {
|
||||||
const user = await currentUser();
|
const user = await currentUser();
|
||||||
if (!user) redirect("/login");
|
if (!user) redirect("/login");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<div className="flex flex-col lg:flex-row w-full">
|
<div className="flex flex-col lg:flex-row w-full">
|
||||||
<ThemeMetaUpdater/>
|
<ThemeMetaUpdater />
|
||||||
<AppSidebar/>
|
<AppSidebar updateNotification={<UpdateNotification />} />
|
||||||
<SidebarInset>
|
<SidebarInset>
|
||||||
<Header/>
|
<Header actions={<ModeToggle />} />
|
||||||
<main className="h-full">{children}</main>
|
<main className="h-full">{children}</main>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
</div>
|
</div>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { LoadingSpinner } from "@/components/wrappers/common/loading/loading-spinner";
|
import { LoadingSpinner } from "@/components/common/loading-spinner";
|
||||||
|
|
||||||
export default function Loading() {
|
export default function Loading() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getCurrentOrganizationSlug } from "@/features/dashboard/organization-cookie";
|
import { getCurrentOrganizationSlug } from "@/features/organizations/organization-cookie";
|
||||||
import { currentUser } from "@/lib/auth/current-user";
|
import { currentUser } from "@/lib/auth/current-user";
|
||||||
|
|
||||||
export default async function Index() {
|
export default async function Index() {
|
||||||
@@ -9,8 +9,4 @@ export default async function Index() {
|
|||||||
redirect(`/dashboard/home`);
|
redirect(`/dashboard/home`);
|
||||||
}
|
}
|
||||||
redirect("/login");
|
redirect("/login");
|
||||||
//Do not delete
|
|
||||||
// return (
|
|
||||||
// <Home/>
|
|
||||||
// );
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +1,54 @@
|
|||||||
import fs from "node:fs";
|
import {NextResponse} from "next/server";
|
||||||
import forge from "node-forge";
|
import {and, eq} from "drizzle-orm";
|
||||||
|
import {db} from "@/db";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
import {logger} from "@/lib/logger";
|
||||||
|
|
||||||
|
const log = logger.child({module: "api/agent/backup/helpers"});
|
||||||
|
|
||||||
export async function decryptedDump(file: File, aesKeyHex: string, ivHex: string, fileExtension: string): Promise<File> {
|
export function withAgentCheck(handler: Function) {
|
||||||
const privateKeyPem = fs.readFileSync("private/keys/server_private.pem", "utf8");
|
return async (request: Request, context: { params: Promise<{ agentId: string }> }) => {
|
||||||
const privateKey = forge.pki.privateKeyFromPem(privateKeyPem);
|
try {
|
||||||
|
const agentId = (await context.params).agentId;
|
||||||
|
|
||||||
// Decrypt AES key with RSA-OAEP
|
const agent = await db.query.agent.findFirst({
|
||||||
const encryptedAesKey = forge.util.hexToBytes(aesKeyHex);
|
where: and(eq(drizzleDb.schemas.agent.id, agentId), eq(drizzleDb.schemas.agent.isArchived, false)),
|
||||||
const aesKey = privateKey.decrypt(encryptedAesKey, "RSA-OAEP", {
|
});
|
||||||
md: forge.md.sha256.create(),
|
|
||||||
mgf1: {md: forge.md.sha256.create()},
|
if (!agent) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{error: "Agent not found"},
|
||||||
|
{status: 404}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return handler(request, {...context, agent});
|
||||||
|
} catch (err) {
|
||||||
|
log.error({error: err, name: "withAgentCheck"}, "Error in agent middleware");
|
||||||
|
return NextResponse.json(
|
||||||
|
{error: "Internal server error"},
|
||||||
|
{status: 500}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDatabaseOrThrow(generatedId: string) {
|
||||||
|
const database = await db.query.database.findFirst({
|
||||||
|
where: eq(drizzleDb.schemas.database.agentDatabaseId, generatedId),
|
||||||
|
with: {
|
||||||
|
project: true,
|
||||||
|
alertPolicies: true,
|
||||||
|
storagePolicies: true
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Read encrypted file content
|
if (!database) {
|
||||||
const encryptedBuffer = Buffer.from(await file.arrayBuffer());
|
throw NextResponse.json(
|
||||||
const iv = forge.util.hexToBytes(ivHex);
|
{error: "Database associated with generatedId not found"},
|
||||||
|
{status: 404}
|
||||||
// AES decryption
|
);
|
||||||
const decipher = forge.cipher.createDecipher("AES-CBC", aesKey);
|
|
||||||
decipher.start({iv});
|
|
||||||
decipher.update(forge.util.createBuffer(encryptedBuffer.toString("binary")));
|
|
||||||
const success = decipher.finish();
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
throw new Error("Decryption failed");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const decryptedBytes = decipher.output.getBytes();
|
return database;
|
||||||
const decryptedBuffer = Buffer.from(decryptedBytes, "binary");
|
}
|
||||||
|
|
||||||
// Return a File so you can use file.arrayBuffer() later
|
|
||||||
return new File(
|
|
||||||
[decryptedBuffer],
|
|
||||||
file.name.replace(/\.enc$/, fileExtension),
|
|
||||||
{type: "application/octet-stream"}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function getFileExtension(dbType: string) {
|
|
||||||
switch (dbType) {
|
|
||||||
case "postgresql":
|
|
||||||
return ".dump";
|
|
||||||
case "mysql":
|
|
||||||
return ".sql";
|
|
||||||
default:
|
|
||||||
return ".dump";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,88 +1,70 @@
|
|||||||
import {NextResponse} from "next/server";
|
import {NextResponse} from "next/server";
|
||||||
import {isUuidv4} from "@/utils/verify-uuid";
|
|
||||||
import {v4 as uuidv4} from "uuid";
|
|
||||||
import {eventEmitter} from "../../../events/route";
|
|
||||||
import * as drizzleDb from "@/db";
|
|
||||||
import {db} from "@/db";
|
|
||||||
import {Backup} from "@/db/schema/07_database";
|
|
||||||
import {and, eq} from "drizzle-orm";
|
import {and, eq} from "drizzle-orm";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
import {db as dbClient, db} from "@/db";
|
||||||
|
import {getDatabaseOrThrow, withAgentCheck} from "./helpers";
|
||||||
|
import {Backup} from "@/db/schema/07_database";
|
||||||
import {withUpdatedAt} from "@/db/utils";
|
import {withUpdatedAt} from "@/db/utils";
|
||||||
import {decryptedDump, getFileExtension} from "./helpers";
|
import {eventEmitter} from "@/lib/event";
|
||||||
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
|
import {sendNotificationsBackupRestore} from "@/features/notifications/notifications.helpers";
|
||||||
import {storeBackupFiles} from "@/features/storages/helpers";
|
import {EventKind} from "@/features/notifications/notifications.types";
|
||||||
|
import {logger} from "@/lib/logger";
|
||||||
|
import {JobLogEntry} from "@/features/logs/types";
|
||||||
|
|
||||||
export async function POST(
|
const log = logger.child({module: "api/agent/backup/route"});
|
||||||
request: Request,
|
|
||||||
{params}: { params: Promise<{ agentId: string }> }
|
export type BodyPost = {
|
||||||
) {
|
method: "manual" | "automatic"
|
||||||
|
generatedId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BodyPatch = {
|
||||||
|
backupId: string
|
||||||
|
status: "success" | "failed"
|
||||||
|
size: number
|
||||||
|
generatedId: string
|
||||||
|
logs: JobLogEntry[]
|
||||||
|
durationMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const POST = withAgentCheck(async (request: Request, {params, agent}: {
|
||||||
|
params: Promise<{ agentId: string }>,
|
||||||
|
agent: any
|
||||||
|
}) => {
|
||||||
try {
|
try {
|
||||||
const contentType = request.headers.get("Content-Type");
|
const body: BodyPost = await request.json();
|
||||||
|
|
||||||
if (!contentType || !contentType.includes("multipart/form-data")) {
|
const method = body.method
|
||||||
return NextResponse.json(
|
const database = await getDatabaseOrThrow(body.generatedId);
|
||||||
{error: "Unsupported or missing Content-Type"},
|
|
||||||
{status: 400}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
eventEmitter.emit('modification', {update: true});
|
|
||||||
|
|
||||||
const agentId = (await params).agentId;
|
|
||||||
const formData = await request.formData();
|
|
||||||
const aesKeyHex = formData.get("aes_key") as string;
|
|
||||||
const ivHex = formData.get("iv") as string;
|
|
||||||
const generatedId = formData.get("generatedId") as string | null;
|
|
||||||
const method = formData.get("method") as string | null;
|
|
||||||
|
|
||||||
if (!generatedId || !isUuidv4(generatedId)) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{error: "generatedId is not a valid UUID"},
|
|
||||||
{status: 400}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const agent = await db.query.agent.findFirst({
|
|
||||||
where: eq(drizzleDb.schemas.agent.id, agentId),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!agent) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{error: "Agent not found"},
|
|
||||||
{status: 404}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const database = await db.query.database.findFirst({
|
|
||||||
where: eq(drizzleDb.schemas.database.agentDatabaseId, generatedId),
|
|
||||||
with: {
|
|
||||||
project: true,
|
|
||||||
alertPolicies: true,
|
|
||||||
storagePolicies: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!database) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{error: "Database associated with generatedId not found"},
|
|
||||||
{status: 404}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let backup: Backup | null | undefined = null;
|
let backup: Backup | null | undefined = null;
|
||||||
|
|
||||||
if (method === "automatic") {
|
if (method === "automatic") {
|
||||||
[backup] = await db
|
|
||||||
.insert(drizzleDb.schemas.backup)
|
|
||||||
.values({
|
|
||||||
status: 'ongoing',
|
|
||||||
databaseId: database.id,
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
|
const ongoingBackup = await db.query.backup.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(drizzleDb.schemas.backup.status, 'ongoing'),
|
||||||
|
eq(drizzleDb.schemas.backup.databaseId, database.id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
if (!backup) {
|
if (!ongoingBackup) {
|
||||||
|
[backup] = await db
|
||||||
|
.insert(drizzleDb.schemas.backup)
|
||||||
|
.values({
|
||||||
|
status: 'ongoing',
|
||||||
|
databaseId: database.id,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
if (!backup) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{error: "Unable to create an automatic backup"},
|
||||||
|
{status: 500}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{error: "Unable to create an automatic backup"},
|
{error: "A backup is already ongoing"},
|
||||||
{status: 500}
|
{status: 500}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -94,7 +76,6 @@ export async function POST(
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
if (!backup) {
|
if (!backup) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{error: "Unable to find the corresponding backup"},
|
{error: "Unable to find the corresponding backup"},
|
||||||
@@ -103,66 +84,100 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = formData.get("status") as string | null;
|
eventEmitter.emit('modification', {update: true});
|
||||||
|
|
||||||
if (status === "success") {
|
return NextResponse.json(
|
||||||
const file = formData.get("file") as File | null;
|
{
|
||||||
const extension = formData.get("extension") as string | null;
|
message: "Init backup success",
|
||||||
if (!aesKeyHex || !ivHex) {
|
backup: backup,
|
||||||
return NextResponse.json({error: "Missing fields"}, {status: 400});
|
},
|
||||||
}
|
{status: 200}
|
||||||
|
);
|
||||||
|
|
||||||
if (!file) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{error: "File is required for successful backup"},
|
|
||||||
{status: 400}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const fileSizeBytes = file.size;
|
|
||||||
// const fileExtension = '.' + (file.name.split('.').pop()?.toLowerCase() || '');
|
|
||||||
const fileExtension = extension ? extension : getFileExtension(database.dbms)
|
|
||||||
const decryptedFile = await decryptedDump(file, aesKeyHex, ivHex, fileExtension);
|
|
||||||
const uuid = uuidv4();
|
|
||||||
const fileName = `${uuid}${fileExtension}`;
|
|
||||||
const buffer = Buffer.from(await decryptedFile.arrayBuffer());
|
|
||||||
|
|
||||||
await storeBackupFiles(backup, database, buffer, fileName)
|
|
||||||
eventEmitter.emit('modification', {update: true});
|
|
||||||
|
|
||||||
await sendNotificationsBackupRestore(database, "success_backup");
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
message: "Backup successfully uploaded",
|
|
||||||
},
|
|
||||||
{status: 200}
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(drizzleDb.schemas.backup)
|
|
||||||
.set(withUpdatedAt({status: 'failed'}))
|
|
||||||
.where(eq(drizzleDb.schemas.backup.id, backup.id));
|
|
||||||
|
|
||||||
eventEmitter.emit('modification', {update: true});
|
|
||||||
|
|
||||||
await sendNotificationsBackupRestore(database, "error_backup");
|
|
||||||
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
message: "Backup successfully updated with status failed",
|
|
||||||
},
|
|
||||||
{status: 200}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error in POST handler:", error);
|
log.error({error: error}, "Error in POST for INIT backup");
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{error: "Internal server error"},
|
{error: "Internal server error"},
|
||||||
{status: 500}
|
{status: 500}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
|
export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
|
||||||
|
params: Promise<{ agentId: string }>,
|
||||||
|
agent: any
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
const body: BodyPatch = await request.json();
|
||||||
|
log.info({data: body}, "Body from PATH in backup route");
|
||||||
|
|
||||||
|
const status = body.status
|
||||||
|
const backupId = body.backupId
|
||||||
|
const backupSize = body.size
|
||||||
|
|
||||||
|
const database = await getDatabaseOrThrow(body.generatedId);
|
||||||
|
|
||||||
|
const backup = await db.query.backup.findFirst({
|
||||||
|
where: eq(drizzleDb.schemas.backup.id, backupId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!backup) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{error: "No backup found"},
|
||||||
|
{status: 500}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [backupUpdated] = await dbClient
|
||||||
|
.update(drizzleDb.schemas.backup)
|
||||||
|
.set(withUpdatedAt({
|
||||||
|
status: status,
|
||||||
|
fileSize: backupSize,
|
||||||
|
durationMs: body.durationMs
|
||||||
|
}))
|
||||||
|
.where(eq(drizzleDb.schemas.backup.id, backup.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
|
||||||
|
const logsToInsert = body.logs.map((entry) => ({
|
||||||
|
backupId: backup.id,
|
||||||
|
restorationId: null,
|
||||||
|
|
||||||
|
loggedAt: new Date(entry.timestamp),
|
||||||
|
|
||||||
|
entryType: entry.type,
|
||||||
|
level: entry.level,
|
||||||
|
|
||||||
|
message: entry.message,
|
||||||
|
command: entry.command ?? null,
|
||||||
|
output: entry.output ?? null,
|
||||||
|
|
||||||
|
exitCode: entry.exit_code ?? null,
|
||||||
|
durationMs: entry.duration_ms ?? null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (logsToInsert.length > 0) {
|
||||||
|
await dbClient
|
||||||
|
.insert(drizzleDb.schemas.jobLog)
|
||||||
|
.values(logsToInsert);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
eventEmitter.emit('modification', {update: true});
|
||||||
|
await sendNotificationsBackupRestore(database, status == "failed" ? "error_backup" : "success_backup" as EventKind);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
message: "Backup successfully updated",
|
||||||
|
backup: backupUpdated,
|
||||||
|
},
|
||||||
|
{status: 200}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
log.error({error: error}, "Error in PATCH backup")
|
||||||
|
return NextResponse.json(
|
||||||
|
{error: "Internal server error"},
|
||||||
|
{status: 500}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import {NextResponse} from "next/server";
|
||||||
|
import {and, eq} from "drizzle-orm";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
import {db} from "@/db";
|
||||||
|
import {getDatabaseOrThrow, withAgentCheck} from "../../helpers";
|
||||||
|
import {isUuidv4} from "@/utils/verify-uuid";
|
||||||
|
import {eventEmitter} from "@/lib/event";
|
||||||
|
import {logger} from "@/lib/logger";
|
||||||
|
|
||||||
|
const log = logger.child({module: "api/agent/backup/upload/init"});
|
||||||
|
|
||||||
|
export type Body = {
|
||||||
|
generatedId: string
|
||||||
|
storageChannelId: string
|
||||||
|
backupId: string
|
||||||
|
}
|
||||||
|
export const POST = withAgentCheck(async (request: Request, {params, agent}: {
|
||||||
|
params: Promise<{ agentId: string }>,
|
||||||
|
agent: any
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
const body: Body = await request.json();
|
||||||
|
|
||||||
|
log.info({data: body}, "Body for backup upload init");
|
||||||
|
|
||||||
|
const generatedId = body.generatedId;
|
||||||
|
const storageChannelId = body.storageChannelId;
|
||||||
|
const backupId = body.backupId;
|
||||||
|
|
||||||
|
if (!generatedId || !isUuidv4(generatedId)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{error: "generatedId is not a valid UUID"},
|
||||||
|
{status: 400}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const database = await getDatabaseOrThrow(generatedId);
|
||||||
|
|
||||||
|
const backup = await db.query.backup.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(drizzleDb.schemas.backup.id, backupId),
|
||||||
|
eq(drizzleDb.schemas.backup.databaseId, database.id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!backup) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{error: "Unable to find the corresponding backup"},
|
||||||
|
{status: 404}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [backupStorage] = await db
|
||||||
|
.insert(drizzleDb.schemas.backupStorage)
|
||||||
|
.values({
|
||||||
|
backupId: backup.id,
|
||||||
|
storageChannelId: storageChannelId,
|
||||||
|
status: "pending",
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
eventEmitter.emit('modification', {update: true});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
message: "Backup storage successfully created",
|
||||||
|
backupStorage: backupStorage
|
||||||
|
},
|
||||||
|
{status: 200}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
log.error({error: error}, "Error in POST for INIT backup");
|
||||||
|
return NextResponse.json(
|
||||||
|
{error: "Internal server error"},
|
||||||
|
{status: 500}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import {NextResponse} from "next/server";
|
||||||
|
import {and, eq} from "drizzle-orm";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
import {db as dbClient, db} from "@/db";
|
||||||
|
import {withUpdatedAt} from "@/db/utils";
|
||||||
|
import {getDatabaseOrThrow, withAgentCheck} from "../../helpers";
|
||||||
|
import {eventEmitter} from "@/lib/event";
|
||||||
|
import {logger} from "@/lib/logger";
|
||||||
|
|
||||||
|
const log = logger.child({module: "api/agent/backup/upload/status"});
|
||||||
|
|
||||||
|
export type Body = {
|
||||||
|
generatedId: string
|
||||||
|
status: "success" | "failed"
|
||||||
|
backupStorageId: string
|
||||||
|
path: string
|
||||||
|
size: number
|
||||||
|
backupId: string
|
||||||
|
}
|
||||||
|
export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
|
||||||
|
params: Promise<{ agentId: string }>,
|
||||||
|
agent: any
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
const body: Body = await request.json();
|
||||||
|
const generatedId = body.generatedId;
|
||||||
|
const status = body.status;
|
||||||
|
const filePath = body.path;
|
||||||
|
const fileSize = body.size;
|
||||||
|
const backupStorageId = body.backupStorageId;
|
||||||
|
const backupId = body.backupId;
|
||||||
|
|
||||||
|
log.info({data: body}, "Body for backup upload status");
|
||||||
|
|
||||||
|
const database = await getDatabaseOrThrow(generatedId);
|
||||||
|
|
||||||
|
const backup = await db.query.backup.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(drizzleDb.schemas.backup.id, backupId),
|
||||||
|
eq(drizzleDb.schemas.backup.databaseId, database.id),
|
||||||
|
),
|
||||||
|
with: {
|
||||||
|
storages: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!backup) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{error: "Unable to find the corresponding backup"},
|
||||||
|
{status: 404}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [backupStorage] = await dbClient
|
||||||
|
.update(drizzleDb.schemas.backupStorage)
|
||||||
|
.set(withUpdatedAt({
|
||||||
|
status: status,
|
||||||
|
path: filePath,
|
||||||
|
size: fileSize
|
||||||
|
}))
|
||||||
|
.where(eq(drizzleDb.schemas.backupStorage.id, backupStorageId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
|
||||||
|
if (backup.storages.length > 0) {
|
||||||
|
const hasSuccessfulStorage = backup.storages.some(
|
||||||
|
(storage) => storage.status === "success"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasSuccessfulStorage && backup.status !== "success") {
|
||||||
|
await db
|
||||||
|
.update(drizzleDb.schemas.backup)
|
||||||
|
.set(withUpdatedAt({
|
||||||
|
status: "success",
|
||||||
|
fileSize: fileSize,
|
||||||
|
}))
|
||||||
|
.where(eq(drizzleDb.schemas.backup.id, backup.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eventEmitter.emit('modification', {update: true});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "Backup status successfully updated",
|
||||||
|
backupStorage: backupStorage
|
||||||
|
},
|
||||||
|
{status: 200}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
log.error({error: error},"Error in POST for INIT backup");
|
||||||
|
return NextResponse.json(
|
||||||
|
{error: "Internal server error"},
|
||||||
|
{status: 500}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
@@ -1,14 +1,20 @@
|
|||||||
import {NextResponse} from "next/server";
|
import {NextResponse} from "next/server";
|
||||||
import {isUuidv4} from "@/utils/verify-uuid";
|
import {isUuidv4} from "@/utils/verify-uuid";
|
||||||
import {eventEmitter} from "../../../events/route";
|
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {db} from "@/db";
|
import {db as dbClient, db} from "@/db";
|
||||||
import {and, eq} from "drizzle-orm";
|
import {and, eq} from "drizzle-orm";
|
||||||
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
|
import {sendNotificationsBackupRestore} from "@/features/notifications/notifications.helpers";
|
||||||
|
import {logger} from "@/lib/logger";
|
||||||
|
import {withUpdatedAt} from "@/db/utils";
|
||||||
|
import {JobLogEntry} from "@/features/logs/types";
|
||||||
|
|
||||||
|
const log = logger.child({module: "api/agent/restore"});
|
||||||
|
|
||||||
export type BodyResultRestore = {
|
export type BodyResultRestore = {
|
||||||
generatedId: string
|
generatedId: string
|
||||||
status: string
|
status: string
|
||||||
|
logs: JobLogEntry[]
|
||||||
|
durationMs: number
|
||||||
}
|
}
|
||||||
type RestorationStatus = 'waiting' | 'ongoing' | 'failed' | 'success';
|
type RestorationStatus = 'waiting' | 'ongoing' | 'failed' | 'success';
|
||||||
|
|
||||||
@@ -19,7 +25,6 @@ export async function POST(
|
|||||||
) {
|
) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
eventEmitter.emit('modification', {update: true});
|
|
||||||
|
|
||||||
const agentId = (await params).agentId
|
const agentId = (await params).agentId
|
||||||
const body: BodyResultRestore = await request.json();
|
const body: BodyResultRestore = await request.json();
|
||||||
@@ -33,7 +38,7 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const agent = await db.query.agent.findFirst({
|
const agent = await db.query.agent.findFirst({
|
||||||
where: eq(drizzleDb.schemas.agent.id, agentId)
|
where: and(eq(drizzleDb.schemas.agent.id, agentId), eq(drizzleDb.schemas.agent.isArchived, false)),
|
||||||
})
|
})
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
return NextResponse.json({error: "Agent not found"}, {status: 404})
|
return NextResponse.json({error: "Agent not found"}, {status: 404})
|
||||||
@@ -58,25 +63,45 @@ export async function POST(
|
|||||||
return NextResponse.json({error: "Unable to fin the corresponding restoration"}, {status: 404})
|
return NextResponse.json({error: "Unable to fin the corresponding restoration"}, {status: 404})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [restorationUpdated] = await db
|
||||||
await db
|
|
||||||
.update(drizzleDb.schemas.restoration)
|
.update(drizzleDb.schemas.restoration)
|
||||||
.set({status: body.status as RestorationStatus})
|
.set(withUpdatedAt({status: body.status as RestorationStatus, durationMs: body.durationMs}))
|
||||||
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
.where(eq(drizzleDb.schemas.restoration.id, restoration.id)).returning();
|
||||||
|
|
||||||
|
|
||||||
await sendNotificationsBackupRestore(database, body.status == "error" ? "error_restore" : "success_restore");
|
const logsToInsert = body.logs.map((entry) => ({
|
||||||
|
backupId: null,
|
||||||
|
restorationId: restorationUpdated.id,
|
||||||
|
|
||||||
const response = {
|
loggedAt: new Date(entry.timestamp),
|
||||||
message: true,
|
|
||||||
details: "Restoration successfully updated"
|
entryType: entry.type,
|
||||||
|
level: entry.level,
|
||||||
|
|
||||||
|
message: entry.message,
|
||||||
|
command: entry.command ?? null,
|
||||||
|
output: entry.output ?? null,
|
||||||
|
|
||||||
|
exitCode: entry.exit_code ?? null,
|
||||||
|
durationMs: entry.duration_ms ?? null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (logsToInsert.length > 0) {
|
||||||
|
await dbClient
|
||||||
|
.insert(drizzleDb.schemas.jobLog)
|
||||||
|
.values(logsToInsert);
|
||||||
}
|
}
|
||||||
|
|
||||||
eventEmitter.emit('modification', {update: true});
|
await sendNotificationsBackupRestore(database, body.status == "failed" ? "error_restore" : "success_restore");
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
status: true,
|
||||||
|
message: "Restoration successfully updated"
|
||||||
|
}
|
||||||
|
|
||||||
return Response.json(response, {status: 200})
|
return Response.json(response, {status: 200})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in POST handler:', error);
|
log.error({error: error}, "Error in POST handler")
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{error: 'Internal server error'},
|
{error: 'Internal server error'},
|
||||||
{status: 500}
|
{status: 500}
|
||||||
|
|||||||
@@ -1,26 +1,28 @@
|
|||||||
import {NextResponse} from "next/server";
|
import {NextResponse} from "next/server";
|
||||||
import {Body} from "./route";
|
import {Body} from "./route";
|
||||||
import {isUuidv4} from "@/utils/verify-uuid";
|
import {isUuidv4} from "@/utils/verify-uuid";
|
||||||
import {getFileUrlPresignedLocal, getFileUrlPreSignedS3Action} from "@/features/upload/private/upload.action";
|
|
||||||
import {Agent} from "@/db/schema/08_agent";
|
import {Agent} from "@/db/schema/08_agent";
|
||||||
import {Database} from "@/db/schema/07_database";
|
import {DatabaseWith} from "@/db/schema/07_database";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {db as dbClient} from "@/db";
|
import {db, db as dbClient} from "@/db";
|
||||||
import {and, eq, inArray} from "drizzle-orm";
|
import {and, eq, inArray} from "drizzle-orm";
|
||||||
import {EDbmsSchema} from "@/db/schema/types";
|
import {dbmsEnumSchema, EDbmsSchema} from "@/db/schema/types";
|
||||||
import {ServerActionResult} from "@/types/action-type";
|
|
||||||
import {SafeActionResult} from "next-safe-action";
|
|
||||||
import {ZodString} from "zod";
|
|
||||||
import {withUpdatedAt} from "@/db/utils";
|
import {withUpdatedAt} from "@/db/utils";
|
||||||
import type {StorageInput} from "@/features/storages/types";
|
import type {StorageInput} from "@/features/storages/storages.types";
|
||||||
import {dispatchStorage} from "@/features/storages/dispatch";
|
import {dispatchStorage} from "@/features/storages/storages.dispatch";
|
||||||
|
import {Setting} from "@/db/schema/01_setting";
|
||||||
|
import {logger} from "@/lib/logger";
|
||||||
|
|
||||||
export async function handleDatabases(body: Body, agent: Agent, lastContact: Date) {
|
const log = logger.child({module: "api/agent/status/helpers"});
|
||||||
|
|
||||||
|
export async function handleDatabases(body: Body, agent: Agent, lastContact: Date, settings: Setting) {
|
||||||
const databasesResponse = [];
|
const databasesResponse = [];
|
||||||
|
|
||||||
const formatDatabase = (database: Database, backupAction: boolean, restoreAction: boolean, UrlBackup: string) => ({
|
const formatDatabase = (database: DatabaseWith, backupAction: boolean, restoreAction: boolean, UrlBackup: string | null, storages: PingDatabaseStorageChannels[], urlMeta: string | null) => ({
|
||||||
generatedId: database.agentDatabaseId,
|
generatedId: database.agentDatabaseId,
|
||||||
dbms: database.dbms,
|
dbms: database.dbms,
|
||||||
|
storages: storages,
|
||||||
|
encrypt: settings.encryption,
|
||||||
data: {
|
data: {
|
||||||
backup: {
|
backup: {
|
||||||
action: backupAction,
|
action: backupAction,
|
||||||
@@ -29,6 +31,7 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
|||||||
restore: {
|
restore: {
|
||||||
action: restoreAction,
|
action: restoreAction,
|
||||||
file: UrlBackup,
|
file: UrlBackup,
|
||||||
|
metaFile: urlMeta
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -36,12 +39,16 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
|||||||
for (const db of body.databases) {
|
for (const db of body.databases) {
|
||||||
|
|
||||||
const existingDatabase = await dbClient.query.database.findFirst({
|
const existingDatabase = await dbClient.query.database.findFirst({
|
||||||
where: eq(drizzleDb.schemas.database.agentDatabaseId, db.generatedId)
|
where: eq(drizzleDb.schemas.database.agentDatabaseId, db.generatedId),
|
||||||
|
with: {
|
||||||
|
project: true
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let backupAction: boolean = false
|
let backupAction: boolean = false
|
||||||
let restoreAction: boolean = false
|
let restoreAction: boolean = false
|
||||||
let urlBackup: string = ""
|
let urlBackup: string | null = null;
|
||||||
|
let urlMeta: string | null = null
|
||||||
|
|
||||||
if (!existingDatabase) {
|
if (!existingDatabase) {
|
||||||
if (!isUuidv4(db.generatedId)) {
|
if (!isUuidv4(db.generatedId)) {
|
||||||
@@ -50,6 +57,12 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
|||||||
{status: 500}
|
{status: 500}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!dbmsEnumSchema.safeParse(db.dbms).success) {
|
||||||
|
log.error({name: "handleDatabases"},`Database type not available: ${db.dbms}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const [databaseCreated] = await dbClient
|
const [databaseCreated] = await dbClient
|
||||||
.insert(drizzleDb.schemas.database)
|
.insert(drizzleDb.schemas.database)
|
||||||
.values({
|
.values({
|
||||||
@@ -57,12 +70,27 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
|||||||
name: db.name,
|
name: db.name,
|
||||||
dbms: db.dbms as EDbmsSchema,
|
dbms: db.dbms as EDbmsSchema,
|
||||||
agentDatabaseId: db.generatedId,
|
agentDatabaseId: db.generatedId,
|
||||||
lastContact: lastContact,
|
lastContact: db.pingStatus ? lastContact : null,
|
||||||
|
healthErrorCount: null
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|
||||||
if (databaseCreated) {
|
if (databaseCreated) {
|
||||||
databasesResponse.push(formatDatabase(databaseCreated, backupAction, restoreAction, urlBackup));
|
|
||||||
|
|
||||||
|
await dbClient
|
||||||
|
.insert(drizzleDb.schemas.healthcheckLog)
|
||||||
|
.values({
|
||||||
|
kind: "database",
|
||||||
|
status: db.pingStatus ? "success" : "failed",
|
||||||
|
objectId: databaseCreated.id,
|
||||||
|
date: lastContact
|
||||||
|
})
|
||||||
|
|
||||||
|
const storages = await getDatabaseStorageChannels(databaseCreated.id)
|
||||||
|
|
||||||
|
databasesResponse.push(formatDatabase(databaseCreated, backupAction, restoreAction, urlBackup, storages, null));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
@@ -71,15 +99,23 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
|||||||
.set(withUpdatedAt({
|
.set(withUpdatedAt({
|
||||||
name: db.name,
|
name: db.name,
|
||||||
agentId: agent.id,
|
agentId: agent.id,
|
||||||
lastContact: lastContact
|
dbms: db.dbms as EDbmsSchema,
|
||||||
|
lastContact: db.pingStatus ? lastContact : existingDatabase.lastContact,
|
||||||
|
healthErrorCount: db.pingStatus ? null : existingDatabase.healthErrorCount,
|
||||||
}))
|
}))
|
||||||
.where(eq(drizzleDb.schemas.database.id, existingDatabase.id))
|
.where(eq(drizzleDb.schemas.database.id, existingDatabase.id))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|
||||||
// const backup = await dbClient.query.backup.findFirst({
|
await dbClient
|
||||||
// where: and(eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.backup.status, "waiting"))
|
.insert(drizzleDb.schemas.healthcheckLog)
|
||||||
// })
|
.values({
|
||||||
|
kind: "database",
|
||||||
|
status: db.pingStatus ? "success" : "failed",
|
||||||
|
objectId: databaseUpdated.id,
|
||||||
|
date: lastContact
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
const activeBackup = await dbClient.query.backup.findFirst({
|
const activeBackup = await dbClient.query.backup.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
@@ -88,7 +124,6 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
const restoration = await dbClient.query.restoration.findFirst({
|
const restoration = await dbClient.query.restoration.findFirst({
|
||||||
where: and(eq(drizzleDb.schemas.restoration.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.restoration.status, "waiting")),
|
where: and(eq(drizzleDb.schemas.restoration.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.restoration.status, "waiting")),
|
||||||
with: {
|
with: {
|
||||||
@@ -96,7 +131,6 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
if (activeBackup && activeBackup.status == "waiting") {
|
if (activeBackup && activeBackup.status == "waiting") {
|
||||||
backupAction = true
|
backupAction = true
|
||||||
|
|
||||||
@@ -109,112 +143,133 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
|||||||
if (restoration) {
|
if (restoration) {
|
||||||
restoreAction = true
|
restoreAction = true
|
||||||
|
|
||||||
|
|
||||||
// const backupToRestore = await dbClient.query.backup.findFirst({
|
|
||||||
// where: eq(drizzleDb.schemas.backup.id, restoration.backupId),
|
|
||||||
// with: {
|
|
||||||
// database: {
|
|
||||||
// with: {
|
|
||||||
// project: true
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// })
|
|
||||||
|
|
||||||
// const [settings] = await dbClient.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
|
|
||||||
// if (!settings) {
|
|
||||||
// return NextResponse.json(
|
|
||||||
// {error: "Unable to find settings"},
|
|
||||||
// {status: 500}
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|
||||||
if (!restoration.backupStorage || restoration.backupStorage.status != "success" || !restoration.backupStorage.path) {
|
if (!restoration.backupStorage || restoration.backupStorage.status != "success" || !restoration.backupStorage.path) {
|
||||||
restoreAction = false
|
restoreAction = false
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const input: StorageInput = {
|
const input: StorageInput = {
|
||||||
action: "get",
|
action: "get",
|
||||||
data: {
|
data: {
|
||||||
path: restoration.backupStorage.path,
|
path: restoration.backupStorage.path,
|
||||||
signedUrl: true,
|
signedUrl: true,
|
||||||
},
|
},
|
||||||
|
metadata: {
|
||||||
|
storageId: restoration.backupStorage.storageChannelId,
|
||||||
|
fileKind: "backups"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputMeta: StorageInput = {
|
||||||
|
action: "get",
|
||||||
|
data: {
|
||||||
|
path: `${restoration.backupStorage.path}.meta`,
|
||||||
|
signedUrl: true,
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
storageId: restoration.backupStorage.storageChannelId,
|
||||||
|
fileKind: "backups"
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await dispatchStorage(input, undefined, restoration.backupStorage.storageChannelId);
|
const result = await dispatchStorage(input, undefined, restoration.backupStorage.storageChannelId);
|
||||||
|
const resultMeta = await dispatchStorage(inputMeta, undefined, restoration.backupStorage.storageChannelId);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
urlBackup = result.url ?? "";
|
urlBackup = result.url ?? null;
|
||||||
|
urlMeta = resultMeta.url ?? null
|
||||||
} else {
|
} else {
|
||||||
await dbClient
|
await dbClient
|
||||||
.update(drizzleDb.schemas.restoration)
|
.update(drizzleDb.schemas.restoration)
|
||||||
.set({status: "failed"})
|
.set(withUpdatedAt({status: "failed"}))
|
||||||
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
||||||
|
|
||||||
const errorMessage = "Failed to get backup URL";
|
const errorMessage = "Failed to get backup URL";
|
||||||
console.error("Restoration failed: ", errorMessage);
|
log.error({error: errorMessage, name: "handleDatabases"}, "Restoration failed");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Restoration crashed unexpectedly:", err);
|
log.error({error: err, name: "handleDatabases"}, "Restoration crashed unexpectedly");
|
||||||
await dbClient
|
await dbClient
|
||||||
.update(drizzleDb.schemas.restoration)
|
.update(drizzleDb.schemas.restoration)
|
||||||
.set({status: "failed"})
|
.set(withUpdatedAt({status: "failed"}))
|
||||||
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// const fileName = backupToRestore?.file
|
|
||||||
//
|
|
||||||
// let data: SafeActionResult<string, ZodString, readonly [], {
|
|
||||||
// _errors?: string[] | undefined;
|
|
||||||
// }, readonly [], ServerActionResult<string>, object> | undefined
|
|
||||||
//
|
|
||||||
// try {
|
|
||||||
//
|
|
||||||
// if (settings.storage == "local") {
|
|
||||||
// data = await getFileUrlPresignedLocal({fileName: fileName!})
|
|
||||||
// } else if (settings.storage == "s3") {
|
|
||||||
//
|
|
||||||
// data = await getFileUrlPreSignedS3Action(`backups/${backupToRestore?.database.project?.slug}/${fileName}`);
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// if (data?.data?.success) {
|
|
||||||
// urlBackup = data.data.value ?? "";
|
|
||||||
// } else {
|
|
||||||
// await dbClient
|
|
||||||
// .update(drizzleDb.schemas.restoration)
|
|
||||||
// .set({status: "failed"})
|
|
||||||
// .where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
|
||||||
//
|
|
||||||
// // @ts-ignore
|
|
||||||
// const errorMessage = data?.data?.actionError?.message || "Failed to get presigned URL";
|
|
||||||
// console.error("Restoration failed: ", errorMessage);
|
|
||||||
//
|
|
||||||
// continue;
|
|
||||||
// }
|
|
||||||
// } catch (err) {
|
|
||||||
// console.error("Restoration crashed unexpectedly:", err);
|
|
||||||
// await dbClient
|
|
||||||
// .update(drizzleDb.schemas.restoration)
|
|
||||||
// .set({status: "failed"})
|
|
||||||
// .where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
|
||||||
//
|
|
||||||
// continue;
|
|
||||||
// }
|
|
||||||
await dbClient
|
await dbClient
|
||||||
.update(drizzleDb.schemas.restoration)
|
.update(drizzleDb.schemas.restoration)
|
||||||
.set({status: "ongoing"})
|
.set(withUpdatedAt({status: "ongoing"}))
|
||||||
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
||||||
}
|
}
|
||||||
databasesResponse.push(formatDatabase(databaseUpdated, backupAction, restoreAction, urlBackup));
|
const storages = await getDatabaseStorageChannels(databaseUpdated.id)
|
||||||
|
databasesResponse.push(formatDatabase(databaseUpdated, backupAction, restoreAction, urlBackup, storages, urlMeta));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return databasesResponse;
|
return databasesResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type PingDatabaseStorageChannels = {
|
||||||
|
id: string;
|
||||||
|
config: any
|
||||||
|
provider: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDatabaseStorageChannels(databaseId: string): Promise<PingDatabaseStorageChannels[]> {
|
||||||
|
|
||||||
|
const database = await db.query.database.findFirst({
|
||||||
|
where: eq(drizzleDb.schemas.database.id, databaseId),
|
||||||
|
with: {
|
||||||
|
project: true,
|
||||||
|
retentionPolicy: true,
|
||||||
|
alertPolicies: true,
|
||||||
|
storagePolicies: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!database) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = await db.query.setting.findFirst({
|
||||||
|
where: eq(drizzleDb.schemas.setting.name, "system"),
|
||||||
|
with: {storageChannel: true},
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultStorageChannel: PingDatabaseStorageChannels[] = settings?.storageChannel
|
||||||
|
? [{
|
||||||
|
id: settings.storageChannel.id,
|
||||||
|
provider: settings.storageChannel.provider,
|
||||||
|
config: settings.storageChannel.config,
|
||||||
|
}]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
|
||||||
|
const enabledDatabaseStorageChannels = await Promise.all(
|
||||||
|
(database.storagePolicies ?? [])
|
||||||
|
.filter(p => p.enabled)
|
||||||
|
.map(async policy => {
|
||||||
|
const storageChannel = await db.query.storageChannel.findFirst({
|
||||||
|
where: eq(drizzleDb.schemas.storageChannel.id, policy.storageChannelId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!storageChannel) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: storageChannel.id,
|
||||||
|
config: storageChannel.config,
|
||||||
|
provider: storageChannel.provider,
|
||||||
|
} as PingDatabaseStorageChannels;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredChannels: PingDatabaseStorageChannels[] = enabledDatabaseStorageChannels.filter(
|
||||||
|
(c): c is PingDatabaseStorageChannels => c !== null
|
||||||
|
);
|
||||||
|
|
||||||
|
return filteredChannels.length > 0 ? filteredChannels : defaultStorageChannel;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
import {NextResponse} from "next/server";
|
import {NextResponse} from "next/server";
|
||||||
import {handleDatabases} from "./helpers";
|
import {handleDatabases} from "./helpers";
|
||||||
import {eventEmitter} from "../../../events/route";
|
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {db} from "@/db";
|
import {db} from "@/db";
|
||||||
import {EDbmsSchema} from "@/db/schema/types";
|
import {EDbmsSchema} from "@/db/schema/types";
|
||||||
import {eq} from "drizzle-orm";
|
import {and, eq} from "drizzle-orm";
|
||||||
import {isUuidv4} from "@/utils/verify-uuid";
|
import {isUuidv4} from "@/utils/verify-uuid";
|
||||||
import {withUpdatedAt} from "@/db/utils";
|
import {withUpdatedAt} from "@/db/utils";
|
||||||
|
import {logger} from "@/lib/logger";
|
||||||
|
|
||||||
|
|
||||||
|
const log = logger.child({module: "api/agent/status/route"});
|
||||||
|
|
||||||
|
|
||||||
export type databaseAgent = {
|
export type databaseAgent = {
|
||||||
name: string,
|
name: string,
|
||||||
dbms: EDbmsSchema,
|
dbms: EDbmsSchema,
|
||||||
generatedId: string
|
generatedId: string
|
||||||
|
pingStatus: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Body = {
|
export type Body = {
|
||||||
@@ -26,12 +31,14 @@ export async function POST(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const agentId = (await params).agentId
|
const agentId = (await params).agentId
|
||||||
|
log.debug(`Agent ID: ${agentId}`)
|
||||||
const body: Body = await request.json();
|
const body: Body = await request.json();
|
||||||
const lastContact = new Date();
|
const lastContact = new Date();
|
||||||
let message: string
|
let message: string
|
||||||
|
|
||||||
if (!isUuidv4(agentId)) {
|
if (!isUuidv4(agentId)) {
|
||||||
message = "agentId is not a valid uuid"
|
message = "agentId is not a valid uuid"
|
||||||
|
log.error({error: message}, "An error occurred")
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{error: "agentId is not a valid uuid"},
|
{error: "agentId is not a valid uuid"},
|
||||||
{status: 500}
|
{status: 500}
|
||||||
@@ -39,24 +46,38 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const agent = await db.query.agent.findFirst({
|
const agent = await db.query.agent.findFirst({
|
||||||
where: eq(drizzleDb.schemas.agent.id, agentId),
|
where: and(eq(drizzleDb.schemas.agent.id, agentId), eq(drizzleDb.schemas.agent.isArchived, false)),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
message = "Agent not found"
|
message = "Agent not found"
|
||||||
return NextResponse.json({error: message}, {status: 404})
|
return NextResponse.json({error: message}, {status: 404})
|
||||||
}
|
}
|
||||||
const databasesResponse = await handleDatabases(body, agent, lastContact)
|
|
||||||
|
const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
|
||||||
|
if (!settings) {
|
||||||
|
return NextResponse.json({error: "An error occured"}, {status: 404})
|
||||||
|
}
|
||||||
|
|
||||||
|
const databasesResponse = await handleDatabases(body, agent, lastContact, settings)
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(drizzleDb.schemas.agent)
|
.update(drizzleDb.schemas.agent)
|
||||||
.set(withUpdatedAt({
|
.set(withUpdatedAt({
|
||||||
version: body.version,
|
version: body.version,
|
||||||
lastContact: lastContact
|
lastContact: lastContact,
|
||||||
|
healthErrorCount: null
|
||||||
}))
|
}))
|
||||||
.where(eq(drizzleDb.schemas.agent.id, agentId));
|
.where(eq(drizzleDb.schemas.agent.id, agentId));
|
||||||
|
|
||||||
eventEmitter.emit('modification', {update: true});
|
await db
|
||||||
|
.insert(drizzleDb.schemas.healthcheckLog)
|
||||||
|
.values({
|
||||||
|
kind: "agent",
|
||||||
|
status: "success",
|
||||||
|
objectId: agentId,
|
||||||
|
date: lastContact
|
||||||
|
})
|
||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
agent: {
|
agent: {
|
||||||
@@ -68,7 +89,7 @@ export async function POST(
|
|||||||
|
|
||||||
return Response.json(response)
|
return Response.json(response)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in POST handler:', error);
|
log.error({error: error}, "Error in POST handler")
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{error: 'Internal server error'},
|
{error: 'Internal server error'},
|
||||||
{status: 500}
|
{status: 500}
|
||||||
|
|||||||
@@ -1,4 +1,35 @@
|
|||||||
import { auth } from "@/lib/auth/auth";
|
import { auth } from "@/lib/auth/auth";
|
||||||
import { toNextJsHandler } from "better-auth/next-js";
|
import { toNextJsHandler } from "better-auth/next-js";
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
export const { GET, POST } = toNextJsHandler(auth.handler);
|
const authHandler = toNextJsHandler(auth.handler);
|
||||||
|
|
||||||
|
async function blockApiKeyCreateForRestrictedUsers(req: NextRequest): Promise<NextResponse | null> {
|
||||||
|
const url = req.nextUrl;
|
||||||
|
if (req.method !== "POST" || !url.pathname.endsWith("/api-key/create")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session?.user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// @ts-ignore
|
||||||
|
if (session.user.banned || (session.user.role as string) === "pending") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Account not eligible to create API keys" },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
return authHandler.GET(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const guard = await blockApiKeyCreateForRestrictedUsers(req);
|
||||||
|
if (guard) return guard;
|
||||||
|
return authHandler.POST(req);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import {NextResponse} from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
PROJECT_URL: process.env.PROJECT_URL,
|
PROJECT_URL: process.env.PROJECT_URL,
|
||||||
PROJECT_NAME: process.env.PROJECT_NAME,
|
PROJECT_NAME: process.env.PROJECT_NAME,
|
||||||
PROJECT_DESCRIPTION: process.env.PROJECT_DESCRIPTION,
|
PROJECT_DESCRIPTION: process.env.PROJECT_DESCRIPTION
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import {EventEmitter} from 'events';
|
|
||||||
import {auth} from "@/lib/auth/auth";
|
import {auth} from "@/lib/auth/auth";
|
||||||
import {headers} from "next/headers";
|
import {headers} from "next/headers";
|
||||||
import {NextResponse} from "next/server";
|
import {NextResponse} from "next/server";
|
||||||
|
import {eventEmitter} from "@/lib/event";
|
||||||
|
import {logger} from "@/lib/logger";
|
||||||
|
|
||||||
export const eventEmitter = new EventEmitter();
|
const log = logger.child({module: "api/events"});
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
|
|
||||||
@@ -18,18 +19,17 @@ export async function GET(request: Request) {
|
|||||||
return new Response(
|
return new Response(
|
||||||
new ReadableStream({
|
new ReadableStream({
|
||||||
start(controller) {
|
start(controller) {
|
||||||
console.log('Stream started');
|
log.info("Stream started");
|
||||||
const handleModification = (data: any) => {
|
const handleModification = (data: any) => {
|
||||||
console.log('Modification event triggered:', data);
|
log.info({data: data},"Modification event triggered");
|
||||||
controller.enqueue(`event: modification\n`);
|
controller.enqueue(`event: modification\n`);
|
||||||
controller.enqueue(`data: ${JSON.stringify(data)}\n\n`);
|
controller.enqueue(`data: ${JSON.stringify(data)}\n\n`);
|
||||||
};
|
};
|
||||||
|
|
||||||
eventEmitter.on('modification', handleModification);
|
eventEmitter.on('modification', handleModification);
|
||||||
|
|
||||||
// Handle client disconnect
|
|
||||||
request.signal.addEventListener('abort', () => {
|
request.signal.addEventListener('abort', () => {
|
||||||
console.log('Client disconnected');
|
log.info("Client disconnected");
|
||||||
controller.close();
|
controller.close();
|
||||||
eventEmitter.off('modification', handleModification);
|
eventEmitter.off('modification', handleModification);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import {NextResponse} from "next/server";
|
import {NextResponse} from "next/server";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import {db} from "@/db";
|
import type {StorageInput} from "@/features/storages/storages.types";
|
||||||
import {eq} from "drizzle-orm";
|
import {dispatchStorage} from "@/features/storages/storages.dispatch";
|
||||||
import * as drizzleDb from "@/db";
|
|
||||||
import type {StorageInput} from "@/features/storages/types";
|
|
||||||
import {dispatchStorage} from "@/features/storages/dispatch";
|
|
||||||
import {Readable} from "node:stream";
|
import {Readable} from "node:stream";
|
||||||
|
import {logger} from "@/lib/logger";
|
||||||
|
|
||||||
|
const log = logger.child({module: "api/files/backups"});
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -15,17 +15,10 @@ export async function GET(
|
|||||||
const token = searchParams.get('token');
|
const token = searchParams.get('token');
|
||||||
const expires = searchParams.get('expires');
|
const expires = searchParams.get('expires');
|
||||||
const pathFromUrl = searchParams.get('path');
|
const pathFromUrl = searchParams.get('path');
|
||||||
|
const storageId = searchParams.get('storageId');
|
||||||
|
|
||||||
if (!pathFromUrl) {
|
if (!pathFromUrl || !storageId) {
|
||||||
return NextResponse.json({error: "Missing file path in search params"}, {status: 404})
|
return NextResponse.json({error: "Missing search params"}, {status: 404})
|
||||||
}
|
|
||||||
|
|
||||||
const localStorageChannel = await db.query.storageChannel.findFirst({
|
|
||||||
where: eq(drizzleDb.schemas.storageChannel.provider, "local"),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!localStorageChannel) {
|
|
||||||
return NextResponse.json({error: "No local storage channel found"})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const input: StorageInput = {
|
const input: StorageInput = {
|
||||||
@@ -34,17 +27,20 @@ export async function GET(
|
|||||||
path: pathFromUrl,
|
path: pathFromUrl,
|
||||||
signedUrl: true,
|
signedUrl: true,
|
||||||
},
|
},
|
||||||
|
metadata: {
|
||||||
|
storageId: storageId,
|
||||||
|
fileKind: "backups",
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
console.debug(input);
|
log.info({input: input}, "Dispatch Storage");
|
||||||
|
|
||||||
const result = await dispatchStorage(input, undefined, localStorageChannel.id);
|
const result = await dispatchStorage(input, undefined, storageId);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return NextResponse.json({error: "Enable to get file from local storage channel, an error occurred !"})
|
return NextResponse.json({error: "Enable to get file from provided storage channel, an error occurred !"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const fileName = path.basename(pathFromUrl);
|
const fileName = path.basename(pathFromUrl);
|
||||||
|
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
@@ -64,14 +60,14 @@ export async function GET(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result.file || !Buffer.isBuffer(result.file)) {
|
if (!result.file || !(result.file instanceof Readable)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{error: "Invalid file payload"},
|
{error: "Invalid file payload"},
|
||||||
{status: 500}
|
{status: 500}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileStream = Readable.from(result.file);
|
const fileStream = Readable.from(result.file as Readable);
|
||||||
|
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
start(controller) {
|
start(controller) {
|
||||||
@@ -1,43 +1,31 @@
|
|||||||
import {NextResponse} from "next/server";
|
import {NextResponse} from "next/server";
|
||||||
import {auth} from "@/lib/auth/auth";
|
import {auth} from "@/lib/auth/auth";
|
||||||
import {headers} from "next/headers";
|
import {headers} from "next/headers";
|
||||||
import {checkFileExistsInBucket, getObjectFromClient} from "@/utils/s3-file-management";
|
import {StorageInput} from "@/features/storages/storages.types";
|
||||||
import {env} from "@/env.mjs";
|
import {dispatchStorage} from "@/features/storages/storages.dispatch";
|
||||||
import * as stream from "node:stream";
|
|
||||||
import path from "path";
|
|
||||||
import {db} from "@/db";
|
|
||||||
import * as drizzleDb from "@/db";
|
|
||||||
import {eq} from "drizzle-orm";
|
|
||||||
import {StorageInput} from "@/features/storages/types";
|
|
||||||
import {dispatchStorage} from "@/features/storages/dispatch";
|
|
||||||
import {Readable} from "node:stream";
|
import {Readable} from "node:stream";
|
||||||
|
import {logger} from "@/lib/logger";
|
||||||
|
|
||||||
|
const log = logger.child({module: "api/files/images"});
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
req: Request,
|
req: Request,
|
||||||
{params}: { params: Promise<{ fileName: string }> }
|
{params}: { params: Promise<{ fileName: string }> }
|
||||||
) {
|
) {
|
||||||
|
const {searchParams} = new URL(req.url);
|
||||||
|
|
||||||
const fileName = (await params).fileName;
|
const fileName = (await params).fileName;
|
||||||
|
const storageId = searchParams.get('storageId');
|
||||||
|
|
||||||
if (!fileName) return NextResponse.json({error: "Missing file parameter"}, {status: 400});
|
if (!fileName) return NextResponse.json({error: "Missing file parameter"}, {status: 400});
|
||||||
|
|
||||||
const session = await auth.api.getSession({headers: await headers()});
|
const session = await auth.api.getSession({headers: await headers()});
|
||||||
if (!session) return NextResponse.json({error: "Unauthorized"}, {status: 403});
|
if (!session) return NextResponse.json({error: "Unauthorized"}, {status: 403});
|
||||||
|
|
||||||
const settings = await db.query.setting.findFirst({
|
|
||||||
where: eq(drizzleDb.schemas.setting.name, "system"),
|
|
||||||
with: {
|
|
||||||
storageChannel: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!settings || !settings.storageChannel) {
|
if (!storageId) {
|
||||||
return NextResponse.json({error: "Unable to get settings or no default storage channel"});
|
return NextResponse.json({error: "Missing storageId in search params"}, {status: 404})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const ext = fileName.split(".").pop()?.toLowerCase();
|
const ext = fileName.split(".").pop()?.toLowerCase();
|
||||||
const contentType =
|
const contentType =
|
||||||
ext === "png"
|
ext === "png"
|
||||||
@@ -58,19 +46,24 @@ export async function GET(
|
|||||||
action: "get",
|
action: "get",
|
||||||
data: {
|
data: {
|
||||||
path: path,
|
path: path,
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
storageId: storageId,
|
||||||
|
fileKind: "images"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await dispatchStorage(input, undefined, settings.storageChannel.id);
|
const result = await dispatchStorage(input, undefined, storageId);
|
||||||
|
|
||||||
if (!result.file || !Buffer.isBuffer(result.file)) {
|
if (!result.file || !(result.file instanceof Readable)) {
|
||||||
|
log.error({error: result}, `An error occurred while getting file`);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{error: "Invalid file payload"},
|
{error: "Invalid file payload"},
|
||||||
{status: 500}
|
{status: 500}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileStream = Readable.from(result.file);
|
const fileStream = Readable.from(result.file as Readable);
|
||||||
|
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
start(controller) {
|
start(controller) {
|
||||||
@@ -89,7 +82,7 @@ export async function GET(
|
|||||||
});
|
});
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error streaming image:", err);
|
log.error({error: err}, `Error streaming image`);
|
||||||
return NextResponse.json({error: "Error fetching file"}, {status: 500});
|
return NextResponse.json({error: "Error fetching file"}, {status: 500});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export async function GET(request: Request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const code = url.searchParams.get("code");
|
||||||
|
|
||||||
|
const success = Boolean(code);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h1>${success ? "Success" : "Failed"}</h1>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
status: success ? 200 : 400,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/html; charset=utf-8",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import {NextResponse} from "next/server";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import {env} from "@/env.mjs";
|
||||||
|
import {logger} from "@/lib/logger";
|
||||||
|
|
||||||
|
const log = logger.child({module: "api/tus/hooks"});
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const event = body.Event
|
||||||
|
const headers = event.HTTPRequest.Header
|
||||||
|
const uploadLength = headers["X-File-Size"]?.[0];
|
||||||
|
const uploadOffset = headers["Upload-Offset"]?.[0];
|
||||||
|
const status = headers["X-Status"]?.[0];
|
||||||
|
|
||||||
|
log.info(`Upload ID : ${event.Upload.ID} (${uploadOffset}/${uploadLength})`);
|
||||||
|
|
||||||
|
if (status === "success") {
|
||||||
|
if (
|
||||||
|
body.Type === "post-receive" &&
|
||||||
|
event.Upload.SizeIsDeferred === false &&
|
||||||
|
event.Upload.Offset === event.Upload.Size
|
||||||
|
) {
|
||||||
|
const id = event.Upload.ID;
|
||||||
|
const fileName = headers["X-File-Name"]?.[0];
|
||||||
|
const filePath = headers["X-File-Path"]?.[0];
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
return NextResponse.json({error: "Missing X-File-Path"}, {status: 500});
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadDir = path.join(env.PRIVATE_PATH!, "/uploads/");
|
||||||
|
|
||||||
|
const oldFilePath = path.join(uploadDir, "tmp", id);
|
||||||
|
const newFilePath = path.join(uploadDir, filePath);
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(newFilePath), {recursive: true});
|
||||||
|
|
||||||
|
let retries = 10;
|
||||||
|
while (!fs.existsSync(oldFilePath)) {
|
||||||
|
if (retries-- === 0) {
|
||||||
|
return NextResponse.json({error: `Upload file not found: ${oldFilePath}`}, {status: 500});
|
||||||
|
}
|
||||||
|
await new Promise(r => setTimeout(r, 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.renameSync(oldFilePath, newFilePath);
|
||||||
|
|
||||||
|
const infoFilePath = `${oldFilePath}.info`;
|
||||||
|
if (fs.existsSync(infoFilePath)) {
|
||||||
|
fs.unlinkSync(infoFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadataHeaderB64 = headers["Upload-Metadata"]?.[0];
|
||||||
|
|
||||||
|
if (metadataHeaderB64) {
|
||||||
|
const metadataHeader = Buffer.from(metadataHeaderB64, "base64").toString("utf-8");
|
||||||
|
if (metadataHeader) {
|
||||||
|
const tomlContent = metadataHeader
|
||||||
|
.split(",")
|
||||||
|
.map((pair) => {
|
||||||
|
const [key, value] = pair.split(" ");
|
||||||
|
const escapedValue = value.replace(/"/g, '\\"');
|
||||||
|
return `${key} = "${escapedValue}"`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
const metaFilePath = `${newFilePath}.meta`;
|
||||||
|
fs.writeFileSync(metaFilePath, tomlContent, "utf-8");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return NextResponse.json({});
|
||||||
|
} catch (error) {
|
||||||
|
log.error({error: error},"TUS Hook error");
|
||||||
|
return NextResponse.json({error: "Internal server error"}, {status: 500});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import {withApiKey} from "@/lib/api-v1/middleware";
|
||||||
|
import {ApiKeyContext} from "@/lib/api-v1/types";
|
||||||
|
import {NextResponse} from "next/server";
|
||||||
|
import {getAgent, resolveAgentAccess} from "@/lib/api-v1/services/agents";
|
||||||
|
import {logger} from "@/lib/logger";
|
||||||
|
import {generateEdgeKey} from "@/utils/edge_key";
|
||||||
|
import {getServerUrl} from "@/utils/get-server-url";
|
||||||
|
|
||||||
|
const log = logger.child({ module: "api/v1/agents/[id]/key" });
|
||||||
|
|
||||||
|
export const GET = withApiKey(
|
||||||
|
async (_req: Request, ctx: ApiKeyContext, params?: Record<string, string>) => {
|
||||||
|
try {
|
||||||
|
const id = params?.id;
|
||||||
|
if (!id) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const access = await resolveAgentAccess(id, ctx.user);
|
||||||
|
|
||||||
|
if (access === "forbidden") return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
if (access === "not_found") return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const agent = await getAgent(id);
|
||||||
|
|
||||||
|
if (!agent) return NextResponse.json({ error: "Agent not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const edgeKey = await generateEdgeKey(getServerUrl(), agent.id);
|
||||||
|
|
||||||
|
return NextResponse.json({ data: edgeKey });
|
||||||
|
} catch (error) {
|
||||||
|
log.error({ error }, "Error in GET /api/v1/agents/[id]");
|
||||||
|
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import {NextResponse} from "next/server";
|
||||||
|
import {withApiKey} from "@/lib/api-v1/middleware";
|
||||||
|
import {logger} from "@/lib/logger";
|
||||||
|
import {ApiKeyContext} from "@/lib/api-v1/types";
|
||||||
|
import {getAgent, resolveAgentAccess} from "@/lib/api-v1/services/agents";
|
||||||
|
import {deleteAgentService} from "@/features/agents/agent-delete.action";
|
||||||
|
|
||||||
|
const log = logger.child({module: "api/v1/agents/[id]"});
|
||||||
|
|
||||||
|
export const GET = withApiKey(
|
||||||
|
async (_req: Request, ctx: ApiKeyContext, params?: Record<string, string>) => {
|
||||||
|
try {
|
||||||
|
const id = params?.id;
|
||||||
|
if (!id) return NextResponse.json({error: "Not found"}, {status: 404});
|
||||||
|
|
||||||
|
const access = await resolveAgentAccess(id, ctx.user);
|
||||||
|
|
||||||
|
if (access === "forbidden") return NextResponse.json({error: "Forbidden"}, {status: 403});
|
||||||
|
if (access === "not_found") return NextResponse.json({error: "Not found"}, {status: 404});
|
||||||
|
|
||||||
|
const agent = await getAgent(id, {
|
||||||
|
includeDatabases: true,
|
||||||
|
includeOrganizations: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!agent) return NextResponse.json({error: "Not found"}, {status: 404});
|
||||||
|
return NextResponse.json({data: agent});
|
||||||
|
} catch (error) {
|
||||||
|
log.error({error}, "Error in GET /api/v1/agents/[id]");
|
||||||
|
return NextResponse.json({error: "Internal server error"}, {status: 500});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const DELETE = withApiKey(
|
||||||
|
async (_req: Request, ctx: ApiKeyContext, params?: Record<string, string>) => {
|
||||||
|
try {
|
||||||
|
const id = params?.id;
|
||||||
|
if (!id) return NextResponse.json({error: "Not found"}, {status: 404});
|
||||||
|
|
||||||
|
const access = await resolveAgentAccess(id, ctx.user);
|
||||||
|
if (access === "forbidden") return NextResponse.json({error: "Forbidden"}, {status: 403});
|
||||||
|
if (access === "not_found") return NextResponse.json({error: "Not found"}, {status: 404});
|
||||||
|
|
||||||
|
|
||||||
|
const agent = await getAgent(id, {
|
||||||
|
includeDatabases: false,
|
||||||
|
includeOrganizations: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!agent) return NextResponse.json({error: "Agent no found"}, {status: 404});
|
||||||
|
|
||||||
|
const organizationIds = agent.organizations.map(org => org.organizationId)
|
||||||
|
|
||||||
|
const canDeleteGlobalAgent =
|
||||||
|
ctx.user.permissions.isAdmin || ctx.user.permissions.isSuperAdmin;
|
||||||
|
|
||||||
|
if (!agent.organizationId && !canDeleteGlobalAgent) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Forbidden" },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteAgentService({
|
||||||
|
agentId: agent.id,
|
||||||
|
organizationId: agent.organizationId ?? undefined,
|
||||||
|
organizationIds: organizationIds
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(null, {status: 204});
|
||||||
|
} catch (error) {
|
||||||
|
log.error({error}, "Error in DELETE /api/v1/agents/[id]");
|
||||||
|
return NextResponse.json({error: "Internal server error"}, {status: 500});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { withApiKey } from "@/lib/api-v1/middleware";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
import { inArray, eq, and, or, isNull } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
import {createAgentService} from "@/features/agents/agents.action";
|
||||||
|
import { ActionError } from "@/lib/safe-actions/actions";
|
||||||
|
import {getAccessibleAgentIds} from "@/lib/api-v1/services/agents";
|
||||||
|
import {ApiKeyContext} from "@/lib/api-v1/types";
|
||||||
|
import {parseJsonBody} from "@/lib/api-v1/validation/json-body";
|
||||||
|
|
||||||
|
const log = logger.child({ module: "api/v1/agents" });
|
||||||
|
|
||||||
|
export const GET = withApiKey(async (_req: Request, ctx: ApiKeyContext) => {
|
||||||
|
try {
|
||||||
|
const agentIds = await getAccessibleAgentIds(ctx.user);
|
||||||
|
|
||||||
|
if (agentIds.length === 0) {
|
||||||
|
return NextResponse.json({ data: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const agents = await db.query.agent.findMany({
|
||||||
|
where: and(
|
||||||
|
inArray(drizzleDb.schemas.agent.id, agentIds),
|
||||||
|
or(
|
||||||
|
eq(drizzleDb.schemas.agent.isArchived, false),
|
||||||
|
isNull(drizzleDb.schemas.agent.isArchived)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ data: agents });
|
||||||
|
} catch (error) {
|
||||||
|
log.error({ error }, "Error in GET /api/v1/agents");
|
||||||
|
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const CreateAgentSchema = z.object({
|
||||||
|
name: z.string().min(1, "name is required"),
|
||||||
|
organizationId: z.string().uuid("organizationId must be a valid UUID").optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const POST = withApiKey(
|
||||||
|
async (req: Request, ctx: ApiKeyContext) => {
|
||||||
|
try {
|
||||||
|
|
||||||
|
const body = await parseJsonBody(req, CreateAgentSchema);
|
||||||
|
|
||||||
|
if (!body.ok) {
|
||||||
|
return body.response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, organizationId } = body.data;
|
||||||
|
|
||||||
|
const org = ctx.organizations.find(
|
||||||
|
(org) => org.id === organizationId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
organizationId &&
|
||||||
|
(!org || !org.permissions.canManageAgents)
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Forbidden" },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const canCreateGlobalAgent =
|
||||||
|
ctx.user.permissions.isAdmin || ctx.user.permissions.isSuperAdmin;
|
||||||
|
|
||||||
|
if (!organizationId && !canCreateGlobalAgent) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Forbidden" },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdAgent = await createAgentService({
|
||||||
|
organizationId,
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
description: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ data: createdAgent },
|
||||||
|
{ status: 201 }
|
||||||
|
);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof ActionError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.error({error},
|
||||||
|
"Error in POST /api/v1/agents"
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { withApiKey } from "@/lib/api-v1/middleware";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
import { ApiKeyContext } from "@/lib/api-v1/types";
|
||||||
|
import {requireDatabaseAccess} from "@/lib/api-v1/services/databases";
|
||||||
|
|
||||||
|
const log = logger.child({
|
||||||
|
module: "api/v1/databases/[id]/backup/[backupId]",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const GET = withApiKey(
|
||||||
|
async (_req: Request, ctx: ApiKeyContext, params?: Record<string, string>) => {
|
||||||
|
try {
|
||||||
|
const guard = await requireDatabaseAccess(params, ctx.user);
|
||||||
|
|
||||||
|
if (!guard.ok) {
|
||||||
|
return guard.response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = guard.data;
|
||||||
|
const backupId = params?.backupId;
|
||||||
|
|
||||||
|
if (!backupId) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const backup = await db.query.backup.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(drizzleDb.schemas.backup.id, backupId),
|
||||||
|
eq(drizzleDb.schemas.backup.databaseId, id),
|
||||||
|
isNull(drizzleDb.schemas.backup.deletedAt)
|
||||||
|
),
|
||||||
|
with: {
|
||||||
|
storages: {
|
||||||
|
where: (backupStorage, { isNull }) =>
|
||||||
|
isNull(backupStorage.deletedAt),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!backup) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ data: backup });
|
||||||
|
} catch (error) {
|
||||||
|
log.error(
|
||||||
|
{ error },
|
||||||
|
"Error in GET /api/v1/databases/[id]/backup/[backupId]"
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { withApiKey } from "@/lib/api-v1/middleware";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
import {and, desc, eq, inArray, isNull} from "drizzle-orm";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
import { ApiKeyContext } from "@/lib/api-v1/types";
|
||||||
|
import {requireDatabaseAccess} from "@/lib/api-v1/services/databases";
|
||||||
|
|
||||||
|
const log = logger.child({ module: "api/v1/databases/[id]/backup" });
|
||||||
|
|
||||||
|
export const GET = withApiKey(
|
||||||
|
async (_req: Request, ctx: ApiKeyContext, params?: Record<string, string>) => {
|
||||||
|
try {
|
||||||
|
const guard = await requireDatabaseAccess(params, ctx.user);
|
||||||
|
|
||||||
|
if (!guard.ok) {
|
||||||
|
return guard.response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = guard.data;
|
||||||
|
|
||||||
|
const backups = await db.query.backup.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(drizzleDb.schemas.backup.databaseId, id),
|
||||||
|
isNull(drizzleDb.schemas.backup.deletedAt)
|
||||||
|
),
|
||||||
|
orderBy: [desc(drizzleDb.schemas.backup.createdAt)],
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ data: backups });
|
||||||
|
} catch (error) {
|
||||||
|
log.error({ error }, "Error in GET /api/v1/databases/[id]/backup");
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const POST = withApiKey(
|
||||||
|
async (_req: Request, ctx: ApiKeyContext, params?: Record<string, string>) => {
|
||||||
|
try {
|
||||||
|
const guard = await requireDatabaseAccess(params, ctx.user);
|
||||||
|
|
||||||
|
if (!guard.ok) {
|
||||||
|
return guard.response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = guard.data;
|
||||||
|
|
||||||
|
const existingBackup = await db.query.backup.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(drizzleDb.schemas.backup.databaseId, id),
|
||||||
|
inArray(drizzleDb.schemas.backup.status, ["waiting", "ongoing"])
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingBackup) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
"A backup is already waiting or ongoing for this database",
|
||||||
|
},
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [createdBackup] = await db
|
||||||
|
.insert(drizzleDb.schemas.backup)
|
||||||
|
.values({
|
||||||
|
databaseId: id,
|
||||||
|
status: "waiting",
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!createdBackup) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to create backup" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ data: createdBackup }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
log.error({ error }, "Error in POST /api/v1/databases/[id]/backup");
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { withApiKey } from "@/lib/api-v1/middleware";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
import {and, eq, inArray, isNull} from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
import { ApiKeyContext } from "@/lib/api-v1/types";
|
||||||
|
import { parseJsonBody } from "@/lib/api-v1/validation/json-body";
|
||||||
|
import {requireDatabaseAccess} from "@/lib/api-v1/services/databases";
|
||||||
|
|
||||||
|
const log = logger.child({ module: "api/v1/databases/[id]/restore" });
|
||||||
|
|
||||||
|
const RestoreSchema = z.object({
|
||||||
|
backupId: z.string().uuid("backupId must be a valid UUID"),
|
||||||
|
backupStorageId: z.string().uuid("backupStorageId must be a valid UUID"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const POST = withApiKey(
|
||||||
|
async (req: Request, ctx: ApiKeyContext, params?: Record<string, string>) => {
|
||||||
|
try {
|
||||||
|
const guard = await requireDatabaseAccess(params, ctx.user);
|
||||||
|
|
||||||
|
if (!guard.ok) {
|
||||||
|
return guard.response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = guard.data;
|
||||||
|
|
||||||
|
const body = await parseJsonBody(req, RestoreSchema);
|
||||||
|
|
||||||
|
if (!body.ok) {
|
||||||
|
return body.response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { backupId, backupStorageId } = body.data;
|
||||||
|
|
||||||
|
const backupRecord = await db.query.backup.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(drizzleDb.schemas.backup.id, backupId),
|
||||||
|
eq(drizzleDb.schemas.backup.databaseId, id),
|
||||||
|
isNull(drizzleDb.schemas.backup.deletedAt)
|
||||||
|
),
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!backupRecord) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Backup not found for this database" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const backupStorage = await db.query.backupStorage.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(drizzleDb.schemas.backupStorage.id, backupStorageId),
|
||||||
|
eq(drizzleDb.schemas.backupStorage.backupId, backupId),
|
||||||
|
isNull(drizzleDb.schemas.backupStorage.deletedAt)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!backupStorage) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Backup storage not found" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backupStorage.status !== "success") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Backup storage is not in a successful state" },
|
||||||
|
{ status: 422 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingRestorationBackup = await db.query.restoration.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(drizzleDb.schemas.restoration.databaseId, id),
|
||||||
|
inArray(drizzleDb.schemas.restoration.status, ["waiting", "ongoing"])
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingRestorationBackup) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
"A restoration is already waiting or ongoing for this database",
|
||||||
|
},
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [restoration] = await db
|
||||||
|
.insert(drizzleDb.schemas.restoration)
|
||||||
|
.values({
|
||||||
|
databaseId: id,
|
||||||
|
backupId,
|
||||||
|
backupStorageId,
|
||||||
|
status: "waiting",
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!restoration) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to create restoration" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ data: restoration }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
log.error({ error }, "Error in POST /api/v1/databases/[id]/restore");
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { withApiKey } from "@/lib/api-v1/middleware";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
import { ApiKeyContext } from "@/lib/api-v1/types";
|
||||||
|
import {requireDatabaseAccess} from "@/lib/api-v1/services/databases";
|
||||||
|
|
||||||
|
const log = logger.child({ module: "api/v1/databases/[id]" });
|
||||||
|
|
||||||
|
export const GET = withApiKey(
|
||||||
|
async (_req: Request, ctx: ApiKeyContext, params?: Record<string, string>) => {
|
||||||
|
try {
|
||||||
|
const guard = await requireDatabaseAccess(params, ctx.user);
|
||||||
|
|
||||||
|
if (!guard.ok) {
|
||||||
|
return guard.response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = guard.data;
|
||||||
|
|
||||||
|
const database = await db.query.database.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(drizzleDb.schemas.database.id, id),
|
||||||
|
isNull(drizzleDb.schemas.database.deletedAt)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!database) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ data: database });
|
||||||
|
} catch (error) {
|
||||||
|
log.error({ error }, "Error in GET /api/v1/databases/[id]");
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { withApiKey } from "@/lib/api-v1/middleware";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
import { ApiKeyContext } from "@/lib/api-v1/types";
|
||||||
|
import {requireDatabaseAccess} from "@/lib/api-v1/services/databases";
|
||||||
|
|
||||||
|
const log = logger.child({ module: "api/v1/databases/[id]/status" });
|
||||||
|
|
||||||
|
export const GET = withApiKey(
|
||||||
|
async (_req: Request, ctx: ApiKeyContext, params?: Record<string, string>) => {
|
||||||
|
try {
|
||||||
|
const guard = await requireDatabaseAccess(params, ctx.user);
|
||||||
|
|
||||||
|
if (!guard.ok) {
|
||||||
|
return guard.response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = guard.data;
|
||||||
|
|
||||||
|
const [database, latestBackup, latestRestoration] = await Promise.all([
|
||||||
|
db.query.database.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(drizzleDb.schemas.database.id, id),
|
||||||
|
isNull(drizzleDb.schemas.database.deletedAt)
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
|
||||||
|
db.query.backup.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(drizzleDb.schemas.backup.databaseId, id),
|
||||||
|
isNull(drizzleDb.schemas.backup.deletedAt)
|
||||||
|
),
|
||||||
|
orderBy: [desc(drizzleDb.schemas.backup.createdAt)],
|
||||||
|
}),
|
||||||
|
|
||||||
|
db.query.restoration.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(drizzleDb.schemas.restoration.databaseId, id),
|
||||||
|
isNull(drizzleDb.schemas.restoration.deletedAt)
|
||||||
|
),
|
||||||
|
orderBy: [desc(drizzleDb.schemas.restoration.createdAt)],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!database) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Database not found" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
data: {
|
||||||
|
isWaitingForBackup: database.isWaitingForBackup,
|
||||||
|
lastContact: database.lastContact,
|
||||||
|
latestBackup: latestBackup ?? null,
|
||||||
|
latestRestoration: latestRestoration ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
log.error({ error }, "Error in GET /api/v1/databases/[id]/status");
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { withApiKey } from "@/lib/api-v1/middleware";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
import { ApiKeyContext } from "@/lib/api-v1/types";
|
||||||
|
import { getAccessibleDatabases } from "@/lib/api-v1/services/databases";
|
||||||
|
|
||||||
|
const log = logger.child({ module: "api/v1/databases" });
|
||||||
|
|
||||||
|
export const GET = withApiKey(async (_req: Request, ctx: ApiKeyContext) => {
|
||||||
|
try {
|
||||||
|
const databases = await getAccessibleDatabases(ctx.user);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
data: databases,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
log.error({ error }, "Error in GET /api/v1/databases");
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const html = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Portabase API Docs</title>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.32.6/swagger-ui.css"
|
||||||
|
integrity="sha384-9Q2fpS+xeS4ffJy6CagnwoUl+4ldAYhOs9pgZuEKxypVModhmZFzeMlvVsAjf7uT"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
/>
|
||||||
|
<style>
|
||||||
|
body { margin: 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="swagger-ui"></div>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.32.6/swagger-ui-bundle.js" integrity="sha384-EYdOaiRwn44zNjrw+Tfs06qYz9BGQVo2f4/pLY5i7VorbjnZNhdplAbTBk8FXHUJ" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.32.6/swagger-ui-standalone-preset.js" integrity="sha384-49fpFaVrAWI/qdgl9Vv5E/4NXxRUiJX5vGuLws1NUpTWGtEqzWEx8gHTw2UTehFK" crossorigin="anonymous"></script>
|
||||||
|
<script>
|
||||||
|
window.onload = function () {
|
||||||
|
SwaggerUIBundle({
|
||||||
|
url: "/api/v1/openapi",
|
||||||
|
dom_id: "#swagger-ui",
|
||||||
|
presets: [
|
||||||
|
SwaggerUIBundle.presets.apis,
|
||||||
|
SwaggerUIStandalonePreset,
|
||||||
|
],
|
||||||
|
layout: "StandaloneLayout",
|
||||||
|
persistAuthorization: true,
|
||||||
|
filter: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
export function GET() {
|
||||||
|
return new Response(html, {
|
||||||
|
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { withApiKey } from "@/lib/api-v1/middleware";
|
||||||
|
import { createPortabaseMcpServer } from "@/lib/mcp/server";
|
||||||
|
import type { ApiKeyContext } from "@/lib/api-v1/types";
|
||||||
|
import { env } from "@/env.mjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MCP endpoint — Streamable HTTP transport, stateless mode.
|
||||||
|
*
|
||||||
|
* Auth is handled by withApiKey before MCP is ever touched.
|
||||||
|
* The validated key is forwarded by MCP tools to downstream /api/v1 REST calls.
|
||||||
|
*/
|
||||||
|
const apiEnabled = env.API_ENABLED;
|
||||||
|
const mcpEnabled = env.MCP_ENABLED;
|
||||||
|
|
||||||
|
export const POST = apiEnabled && mcpEnabled
|
||||||
|
? withApiKey(async (req: Request, ctx: ApiKeyContext) => {
|
||||||
|
const apiKey = req.headers.get("x-api-key")!;
|
||||||
|
|
||||||
|
const server = createPortabaseMcpServer(ctx, apiKey);
|
||||||
|
|
||||||
|
const transport = new WebStandardStreamableHTTPServerTransport({
|
||||||
|
sessionIdGenerator: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
await server.connect(transport);
|
||||||
|
|
||||||
|
return transport.handleRequest(req);
|
||||||
|
})
|
||||||
|
: () => NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { buildSpec } from "@/lib/api-v1/openapi/spec";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export function GET() {
|
||||||
|
return NextResponse.json(buildSpec());
|
||||||
|
}
|
||||||
+30
-82
@@ -1,85 +1,33 @@
|
|||||||
// "use client"
|
"use client";
|
||||||
//
|
|
||||||
// import {useSearchParams} from "next/navigation"
|
|
||||||
//
|
|
||||||
// enum Error {
|
|
||||||
// Configuration = "Configuration",
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// const errorMap = {
|
|
||||||
// [Error.Configuration]: (
|
|
||||||
// <p>
|
|
||||||
// There was a problem when trying to authenticate. Please contact us if this
|
|
||||||
// error persists. Unique error code:{" "}
|
|
||||||
// <code className="rounded-sm bg-slate-100 p-1 text-xs">Configuration</code>
|
|
||||||
// </p>
|
|
||||||
// ),
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// export default function AuthErrorPage() {
|
|
||||||
// const search = useSearchParams()
|
|
||||||
// const error = search.get("error") as Error
|
|
||||||
//
|
|
||||||
// return (
|
|
||||||
// <div className="flex h-screen w-full flex-col items-center justify-center">
|
|
||||||
// <a
|
|
||||||
// href="#"
|
|
||||||
// className="block max-w-sm rounded-lg border border-gray-200 bg-white p-6 text-center shadow-sm hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-700"
|
|
||||||
// >
|
|
||||||
// <h5 className="mb-2 flex flex-row items-center justify-center gap-2 text-xl font-bold tracking-tight text-gray-900 dark:text-white">
|
|
||||||
// Something went wrong
|
|
||||||
// </h5>
|
|
||||||
// <div className="font-normal text-gray-700 dark:text-gray-400">
|
|
||||||
// {errorMap[error] || "Please contact us if this error persists."}
|
|
||||||
// </div>
|
|
||||||
// </a>
|
|
||||||
// </div>
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
"use client"
|
|
||||||
|
|
||||||
import { useSearchParams } from "next/navigation"
|
import { Loader2 } from "lucide-react";
|
||||||
import React, { Suspense } from "react"
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useSession } from "@/lib/auth/auth-client";
|
||||||
|
|
||||||
enum Error {
|
export default function ErrorPage() {
|
||||||
Configuration = "Configuration",
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const { data: session, isPending } = useSession();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isPending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = searchParams.get("error");
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (error) {
|
||||||
|
params.set("error", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const destination = session ? "/dashboard/home" : "/login";
|
||||||
|
router.replace(`${destination}?${params.toString()}`);
|
||||||
|
}, [isPending, session, router, searchParams]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen w-full items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const errorMap = {
|
|
||||||
[Error.Configuration]: (
|
|
||||||
<p>
|
|
||||||
There was a problem when trying to authenticate. Please contact us if this
|
|
||||||
error persists. Unique error code:{" "}
|
|
||||||
<code className="rounded-sm bg-slate-100 p-1 text-xs">Configuration</code>
|
|
||||||
</p>
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
function AuthErrorPageContent() {
|
|
||||||
const search = useSearchParams()
|
|
||||||
const error = search.get("error") as Error
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-screen w-full flex-col items-center justify-center">
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className="block max-w-sm rounded-lg border border-gray-200 bg-white p-6 text-center shadow-sm hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-700"
|
|
||||||
>
|
|
||||||
<h5 className="mb-2 flex flex-row items-center justify-center gap-2 text-xl font-bold tracking-tight text-gray-900 dark:text-white">
|
|
||||||
Something went wrong
|
|
||||||
</h5>
|
|
||||||
<div className="font-normal text-gray-700 dark:text-gray-400">
|
|
||||||
{errorMap[error] || "Please contact us if this error persists."}
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AuthErrorPage() {
|
|
||||||
return (
|
|
||||||
<Suspense fallback={<div>Loading...</div>}>
|
|
||||||
<AuthErrorPageContent />
|
|
||||||
</Suspense>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+26
-11
@@ -6,8 +6,11 @@
|
|||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--font-sans: var(--font-geist-sans);
|
--font-sans: var(--font-poppins), ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||||
|
--font-title: var(--font-poppins), var(--font-poppins), sans-serif;
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono);
|
||||||
|
--font-author: var(--font-author);
|
||||||
|
--font-poppins: var(--font-poppins);
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
@@ -71,7 +74,7 @@
|
|||||||
--card-foreground: oklch(0.145 0 0);
|
--card-foreground: oklch(0.145 0 0);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
--primary: oklch(0.205 0 0);
|
--primary: oklch(0.611 0.204 42.149);
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
--secondary: oklch(0.97 0 0);
|
--secondary: oklch(0.97 0 0);
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
@@ -82,7 +85,7 @@
|
|||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--border: oklch(0.922 0 0);
|
--border: oklch(0.922 0 0);
|
||||||
--input: oklch(0.922 0 0);
|
--input: oklch(0.922 0 0);
|
||||||
--ring: oklch(0.708 0 0);
|
--ring: oklch(0.611 0.204 42.149);
|
||||||
--chart-1: oklch(0.646 0.222 41.116);
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
--chart-2: oklch(0.6 0.118 184.704);
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
--chart-3: oklch(0.398 0.07 227.392);
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
@@ -90,12 +93,12 @@
|
|||||||
--chart-5: oklch(0.769 0.188 70.08);
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
--sidebar: oklch(0.985 0 0);
|
--sidebar: oklch(0.985 0 0);
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
--sidebar-primary: oklch(0.611 0.204 42.149);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
--sidebar-ring: oklch(0.611 0.204 42.149);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@@ -105,8 +108,8 @@
|
|||||||
--card-foreground: oklch(0.985 0 0);
|
--card-foreground: oklch(0.985 0 0);
|
||||||
--popover: oklch(0.205 0 0);
|
--popover: oklch(0.205 0 0);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
--primary: oklch(0.922 0 0);
|
--primary: oklch(0.704 0.191 47.132);
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
--secondary: oklch(0.269 0 0);
|
--secondary: oklch(0.269 0 0);
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
--muted: oklch(0.269 0 0);
|
--muted: oklch(0.269 0 0);
|
||||||
@@ -116,7 +119,7 @@
|
|||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(1 0 0 / 10%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(1 0 0 / 15%);
|
||||||
--ring: oklch(0.556 0 0);
|
--ring: oklch(0.704 0.191 47.132);
|
||||||
--chart-1: oklch(0.488 0.243 264.376);
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
--chart-2: oklch(0.696 0.17 162.48);
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
--chart-3: oklch(0.769 0.188 70.08);
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
@@ -124,12 +127,12 @@
|
|||||||
--chart-5: oklch(0.645 0.246 16.439);
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
--sidebar: oklch(0.205 0 0);
|
--sidebar: oklch(0.205 0 0);
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
--sidebar-primary: oklch(0.704 0.191 47.132);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
--sidebar-ring: oklch(0.704 0.191 47.132);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@@ -137,6 +140,18 @@
|
|||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground font-sans;
|
||||||
|
}
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
@apply font-title;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-hide {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-29
@@ -1,38 +1,42 @@
|
|||||||
import React from "react";
|
import type { Metadata } from "next";
|
||||||
import type {Metadata} from "next";
|
import type React from "react";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import {Providers} from "./providers";
|
import { ConsoleSilencer } from "@/components/common/console-silencer";
|
||||||
import {cn} from "@/lib/utils";
|
import { author, geistMono, poppins } from "@/fonts/fonts";
|
||||||
import {ConsoleSilencer} from "@/components/wrappers/common/console-silencer";
|
import { cn } from "@/lib/utils";
|
||||||
import {inter} from "@/fonts/fonts";
|
import { Providers } from "./providers";
|
||||||
|
|
||||||
const title = process.env.PROJECT_NAME ?? "App Title";
|
const title = process.env.PROJECT_NAME ?? "Portabase";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: {
|
title: {
|
||||||
default: title,
|
default: title,
|
||||||
template: `%s - ${title}`
|
template: `%s - ${title}`,
|
||||||
},
|
},
|
||||||
description: process.env.PROJECT_DESCRIPTION ?? undefined,
|
description: process.env.PROJECT_DESCRIPTION ?? undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function RootLayout({
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
|
return (
|
||||||
return (
|
<html lang="en" suppressHydrationWarning>
|
||||||
<html lang="en" suppressHydrationWarning>
|
<head>
|
||||||
<head>
|
<meta name="apple-mobile-web-app-title" content={title} />
|
||||||
<meta name="apple-mobile-web-app-title" content={title}/>
|
</head>
|
||||||
</head>
|
<body
|
||||||
<body className={cn(inter.className, "h-full")}>
|
className={cn(
|
||||||
<ConsoleSilencer/>
|
poppins.variable,
|
||||||
<Providers>
|
author.variable,
|
||||||
{children}
|
geistMono.variable,
|
||||||
</Providers>
|
"font-sans h-full",
|
||||||
</body>
|
)}
|
||||||
</html>
|
>
|
||||||
);
|
<ConsoleSilencer />
|
||||||
|
<Providers>{children}</Providers>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-11
@@ -1,15 +1,19 @@
|
|||||||
import BackButton from "@/components/wrappers/common/button/back-button";
|
import BackButton from "@/components/common/back-button";
|
||||||
|
|
||||||
export default async function NotFound() {
|
export default async function NotFound() {
|
||||||
return(
|
return (
|
||||||
<div className="flex items-center min-h-screen px-4 py-12 sm:px-6 md:px-8 lg:px-12 xl:px-16">
|
<div className="flex items-center min-h-screen px-4 py-12 sm:px-6 md:px-8 lg:px-12 xl:px-16">
|
||||||
<div className="w-full space-y-6 text-center">
|
<div className="w-full space-y-6 text-center">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h1 className="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl">Not found</h1>
|
<h1 className="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl">
|
||||||
<p className="leading-7 [&:not(:first-child)]:mt-6">The content you are trying to view is not available.</p>
|
Not found
|
||||||
</div>
|
</h1>
|
||||||
<BackButton>Go home</BackButton>
|
<p className="leading-7 not-first:mt-6">
|
||||||
</div>
|
The content you are trying to view is not available.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
<BackButton>Go home</BackButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-22
@@ -1,31 +1,28 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {PropsWithChildren, Suspense} from "react";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import {ThemeProvider} from "@/features/theme/theme-provider";
|
import { type PropsWithChildren, Suspense } from "react";
|
||||||
|
|
||||||
import {Toaster} from "@/components/ui/sonner";
|
|
||||||
import {QueryClient, QueryClientProvider} from "@tanstack/react-query";
|
|
||||||
import {ThemeMetaUpdaterRoot} from "@/features/browser/theme-meta-updater-root";
|
|
||||||
import {BackupModalProvider} from "@/components/wrappers/dashboard/database/backup/backup-modal-context";
|
|
||||||
|
|
||||||
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
import { ErrorLayout } from "@/components/common/error-layout";
|
||||||
|
import { ThemeMetaUpdaterRoot } from "@/features/theme/theme-meta-updater-root";
|
||||||
|
import { ThemeProvider } from "@/features/theme/theme-provider";
|
||||||
|
|
||||||
export type ProviderProps = PropsWithChildren<{}>;
|
export type ProviderProps = PropsWithChildren<{}>;
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
export const Providers = (props: ProviderProps) => {
|
export const Providers = (props: ProviderProps) => {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<ThemeProvider
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
attribute="class"
|
<ThemeMetaUpdaterRoot />
|
||||||
defaultTheme="system"
|
<QueryClientProvider client={queryClient}>
|
||||||
enableSystem
|
<ErrorLayout>
|
||||||
>
|
<Toaster />
|
||||||
<ThemeMetaUpdaterRoot/>
|
{props.children}
|
||||||
<QueryClientProvider client={queryClient}>
|
</ErrorLayout>
|
||||||
<Toaster/>
|
</QueryClientProvider>
|
||||||
{props.children}
|
</ThemeProvider>
|
||||||
</QueryClientProvider>
|
</Suspense>
|
||||||
</ThemeProvider>
|
);
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
name: portabase-dev-func
|
||||||
|
|
||||||
|
services:
|
||||||
|
keycloak:
|
||||||
|
image: quay.io/keycloak/keycloak:latest
|
||||||
|
command: start-dev --import-realm
|
||||||
|
# environment:
|
||||||
|
# KC_BOOTSTRAP_ADMIN_USERNAME: admin
|
||||||
|
# KC_BOOTSTRAP_ADMIN_PASSWORD: admin
|
||||||
|
ports:
|
||||||
|
- "3056:8080"
|
||||||
|
volumes:
|
||||||
|
- keycloak-data:/opt/keycloak/data
|
||||||
|
- ./seeds/keycloak:/opt/keycloak/data/import:ro
|
||||||
|
- ./export:/tmp/export
|
||||||
|
pocket-id:
|
||||||
|
image: ghcr.io/pocket-id/pocket-id
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- APP_URL=http://localhost:3055
|
||||||
|
- ENCRYPTION_KEY=QwHyjbZvSsDUAcjpdmSPsuYxaH6vET6OeBaeLwXccCb43L6Om3W1AoU5pKIJTzYr
|
||||||
|
ports:
|
||||||
|
- 3055:1411
|
||||||
|
volumes:
|
||||||
|
- pocket-id-data:/app/data
|
||||||
|
- ./seeds/pocket-id:/seed:ro
|
||||||
|
healthcheck:
|
||||||
|
test: "curl -f http://localhost:1411/healthz"
|
||||||
|
interval: 1m30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 2
|
||||||
|
start_period: 10s
|
||||||
|
volumes:
|
||||||
|
keycloak-data:
|
||||||
|
pocket-id-data:
|
||||||
+10
-4
@@ -1,23 +1,28 @@
|
|||||||
name: portabase-prod
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|
||||||
app:
|
app:
|
||||||
# build:
|
# build:
|
||||||
# context: .
|
# context: .
|
||||||
# dockerfile: docker/dockerfile/Dockerfile
|
# dockerfile: docker/dockerfile/Dockerfile
|
||||||
# target: prod
|
# target: prod
|
||||||
image: solucetechnologies/portabase:1.1.4-rc.2
|
image: portabase/portabase:1
|
||||||
ports:
|
ports:
|
||||||
- '8887:80'
|
- '8887:80'
|
||||||
environment:
|
environment:
|
||||||
TZ: "Europe/Paris"
|
TZ: "Europe/Paris"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
volumes:
|
||||||
|
- portabase-data:/data
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
container_name: portabase-app-prod
|
container_name: portabase-app-prod
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -f http://localhost/api/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 60s
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
@@ -36,4 +41,5 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
portabase-data:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
|
|||||||
@@ -17,5 +17,49 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
|
tusd:
|
||||||
|
image: tusproject/tusd:v2.8.0
|
||||||
|
ports:
|
||||||
|
- "1080:8080"
|
||||||
|
command: >
|
||||||
|
-upload-dir /data/uploads/tmp
|
||||||
|
-hooks-http http://localhost:8887/api/tus/hooks
|
||||||
|
-max-size 21474836480
|
||||||
|
-base-path /tus/files/
|
||||||
|
extra_hosts:
|
||||||
|
- "localhost:host-gateway"
|
||||||
|
volumes:
|
||||||
|
- ./private/uploads/tmp:/data/uploads/tmp
|
||||||
|
user: "1000:1000"
|
||||||
|
|
||||||
|
mailpit:
|
||||||
|
image: axllent/mailpit:latest
|
||||||
|
container_name: portabase-mailpit-dev
|
||||||
|
hostname: mailpit
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8025:8025"
|
||||||
|
- "1025:1025"
|
||||||
|
|
||||||
|
azurite:
|
||||||
|
image: mcr.microsoft.com/azure-storage/azurite
|
||||||
|
command: azurite --blobHost 0.0.0.0 --skipApiVersionCheck
|
||||||
|
ports:
|
||||||
|
- "10000:10000"
|
||||||
|
volumes:
|
||||||
|
- azurite-data:/data
|
||||||
|
extra_hosts:
|
||||||
|
- "localhost:host-gateway"
|
||||||
|
networks:
|
||||||
|
- portabase
|
||||||
|
|
||||||
|
# DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;QueueEndpoint=http://127.0.0.1:10001/devstoreaccount1;TableEndpoint=http://127.0.0.1:10002/devstoreaccount1;
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
|
azurite-data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
portabase:
|
||||||
|
name: portabase_network
|
||||||
|
external: true
|
||||||
|
|||||||
@@ -1,53 +1,79 @@
|
|||||||
FROM node:22-alpine AS base
|
FROM --platform=$BUILDPLATFORM node:22-bullseye AS base
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
curl \
|
||||||
|
ca-certificates \
|
||||||
|
bash \
|
||||||
|
tzdata \
|
||||||
|
libc6 \
|
||||||
|
build-essential \
|
||||||
|
g++ \
|
||||||
|
make \
|
||||||
|
autoconf \
|
||||||
|
automake \
|
||||||
|
libtool \
|
||||||
|
git \
|
||||||
|
nginx \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|
||||||
|
RUN mkdir -p /etc/apt/keyrings \
|
||||||
|
&& curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
|
||||||
|
| gpg --dearmor -o /etc/apt/keyrings/postgresql.gpg \
|
||||||
|
&& echo "deb [signed-by=/etc/apt/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt bullseye-pgdg main" \
|
||||||
|
> /etc/apt/sources.list.d/pgdg.list
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
postgresql-18 \
|
||||||
|
postgresql-client-18 \
|
||||||
|
postgresql-contrib-18 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
ENV PATH="/usr/lib/postgresql/18/bin:${PATH}"
|
||||||
|
|
||||||
|
|
||||||
|
FROM --platform=$BUILDPLATFORM golang:1.23-bookworm AS tusd-base
|
||||||
|
|
||||||
|
ARG TUSD_VERSION=2.8.0
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
RUN git clone https://github.com/tus/tusd.git . \
|
||||||
|
&& git checkout v${TUSD_VERSION}
|
||||||
|
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} \
|
||||||
|
go build -ldflags="-s -w" -o /tusddist/tusd ./cmd/tusd
|
||||||
|
|
||||||
|
FROM scratch AS tusd-dist
|
||||||
|
COPY --from=tusd-base /tusddist/tusd /tusd
|
||||||
|
|
||||||
RUN apk add --update --no-cache \
|
|
||||||
libc6-compat \
|
|
||||||
openssl \
|
|
||||||
tzdata
|
|
||||||
|
|
||||||
FROM base AS build-env
|
FROM base AS build-env
|
||||||
|
|
||||||
RUN apk add --update --no-cache \
|
|
||||||
build-base \
|
|
||||||
g++ \
|
|
||||||
make \
|
|
||||||
libtool \
|
|
||||||
autoconf \
|
|
||||||
automake
|
|
||||||
|
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
FROM build-env AS deps
|
FROM build-env AS deps
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
|
|
||||||
RUN pnpm i --frozen-lockfile
|
RUN pnpm i --frozen-lockfile
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
FROM build-env AS dev
|
FROM build-env AS dev
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY --from=tusd-base /usr/local/bin/tusd /usr/local/bin/tusd
|
||||||
|
WORKDIR /app
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
USER root
|
USER root
|
||||||
|
|
||||||
RUN chmod +x /app/docker/entrypoints/app-dev-entrypoint.sh
|
RUN chmod +x /app/docker/entrypoints/app-dev-entrypoint.sh
|
||||||
|
|
||||||
ENTRYPOINT ["sh","/app/docker/entrypoints/app-dev-entrypoint.sh"]
|
ENTRYPOINT ["sh","/app/docker/entrypoints/app-dev-entrypoint.sh"]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
FROM build-env AS builder
|
FROM build-env AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -55,51 +81,45 @@ COPY --from=deps /app/node_modules ./node_modules
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
RUN pnpm run build
|
RUN pnpm run build
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
FROM base AS prod
|
FROM base AS prod
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV PORT=80
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
ENV PGDATA=/data/postgres
|
||||||
|
ENV PRIVATE_PATH=/data/private
|
||||||
|
ENV TUSD_BEHIND_PROXY=false
|
||||||
|
|
||||||
|
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
RUN adduser --system --uid 1001 nextjs
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
|
|
||||||
RUN mkdir .next
|
|
||||||
RUN chown nextjs:nodejs .next
|
|
||||||
RUN mkdir -p /app/private/uploads
|
|
||||||
RUN chown -R nextjs:nodejs /app/private
|
|
||||||
RUN chown -R nextjs:nodejs /app/public
|
|
||||||
|
|
||||||
|
|
||||||
COPY --from=builder /app/next.config.ts ./
|
COPY --from=builder /app/next.config.ts ./
|
||||||
COPY --from=builder /app/portabase.config.ts ./
|
COPY --from=builder /app/portabase.config.ts ./
|
||||||
COPY --from=builder /app/drizzle.config.ts ./
|
COPY --from=builder /app/drizzle.config.ts ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=1001:1001 /app/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=1001:1001 /app/.next/static ./.next/static
|
||||||
COPY --chown=nextjs:nodejs src/db ./src/db
|
COPY --chown=1001:1001 src/db ./src/db
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
|
||||||
|
COPY --from=tusd-dist /tusd /usr/local/bin/tusd
|
||||||
|
RUN chmod +x /usr/local/bin/tusd
|
||||||
|
|
||||||
|
COPY docker/nginx/nginx.conf /etc/nginx/nginx.conf
|
||||||
|
|
||||||
|
|
||||||
|
RUN mkdir -p .next /data/private/uploads \
|
||||||
|
&& chown -R nextjs:nodejs .next /data/private /app/public
|
||||||
|
|
||||||
|
|
||||||
USER root
|
USER root
|
||||||
|
|
||||||
COPY ./docker/entrypoints/app-prod-entrypoint.sh /app/app-prod-entrypoint.sh
|
COPY ./docker/entrypoints/app-prod-entrypoint.sh /app/app-prod-entrypoint.sh
|
||||||
RUN chmod +x /app/app-prod-entrypoint.sh
|
RUN chmod +x /app/app-prod-entrypoint.sh
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
ENV PORT=80
|
|
||||||
|
|
||||||
ENV HOSTNAME="0.0.0.0"
|
|
||||||
|
|
||||||
USER nextjs
|
|
||||||
|
|
||||||
ENTRYPOINT ["sh","/app/app-prod-entrypoint.sh"]
|
ENTRYPOINT ["sh","/app/app-prod-entrypoint.sh"]
|
||||||
|
|||||||
@@ -7,8 +7,88 @@ else
|
|||||||
echo "[WARN] No TZ provided, using default container timezone"
|
echo "[WARN] No TZ provided, using default container timezone"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
POSTGRES_BIN=$(ls -d /usr/lib/postgresql/*/bin | head -n 1)
|
||||||
|
|
||||||
node server.js
|
if [ -z "$POSTGRES_BIN" ]; then
|
||||||
|
echo "PostgreSQL binaries not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
export PATH="$POSTGRES_BIN:$PATH"
|
||||||
|
|
||||||
|
if [ -z "$DATABASE_URL" ]; then
|
||||||
|
echo "[INFO] No DATABASE_URL provided, starting internal Postgres..."
|
||||||
|
|
||||||
|
mkdir -p "$PGDATA"
|
||||||
|
chown -R postgres:postgres "$PGDATA"
|
||||||
|
|
||||||
|
if [ ! -f "$PGDATA/PG_VERSION" ]; then
|
||||||
|
echo "[INFO] Initializing database cluster..."
|
||||||
|
if ! su postgres -c "initdb -D '$PGDATA'" > /dev/null 2>&1; then
|
||||||
|
echo "[ERROR] initdb failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! su postgres -c "pg_ctl -D '$PGDATA' \
|
||||||
|
-o \"-c listen_addresses='localhost' -c logging_collector=on\" \
|
||||||
|
-l $PGDATA/postgres.log -w start" > /dev/null 2>&1; then
|
||||||
|
echo "[ERROR] PostgreSQL failed to start"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
until su postgres -c "pg_isready -h 127.0.0.1 -p 5432" > /dev/null 2>&1; do
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[INFO] PostgreSQL server is up and accepting connections"
|
||||||
|
|
||||||
|
DB_USER="${POSTGRES_USER:-portabase_user}"
|
||||||
|
DB_PASS="${POSTGRES_PASSWORD:-JaB6b1SUtIWYvt7srnOt}"
|
||||||
|
DB_NAME="${POSTGRES_DB:-portabase_db}"
|
||||||
|
|
||||||
|
USER_EXISTS=$(su postgres -c "psql -tAc \"SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'\"" 2>/dev/null)
|
||||||
|
if [ "$USER_EXISTS" != "1" ]; then
|
||||||
|
if ! su postgres -c "psql -c \"CREATE USER $DB_USER WITH PASSWORD '$DB_PASS';\"" > /dev/null 2>&1; then
|
||||||
|
echo "[ERROR] Failed creating user"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
DB_EXISTS=$(su postgres -c "psql -tAc \"SELECT 1 FROM pg_database WHERE datname='$DB_NAME'\"" 2>/dev/null)
|
||||||
|
if [ "$DB_EXISTS" != "1" ]; then
|
||||||
|
if ! su postgres -c "psql -c \"CREATE DATABASE $DB_NAME OWNER $DB_USER;\"" > /dev/null 2>&1; then
|
||||||
|
echo "[ERROR] Failed creating database"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
export DATABASE_URL="postgres://$DB_USER:$DB_PASS@127.0.0.1:5432/$DB_NAME"
|
||||||
|
|
||||||
|
echo "[SUCCESS] Internal PostgreSQL started successfully"
|
||||||
|
echo "[SUCCESS] Database: $DB_NAME | User: $DB_USER | Host: 127.0.0.1:5432"
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
mkdir -p /data/private/uploads/tmp
|
||||||
|
echo "▶ Starting tusd server..."
|
||||||
|
TUSD_BEHIND_PROXY_FLAG=""
|
||||||
|
if [ "${TUSD_BEHIND_PROXY:-false}" = "true" ]; then
|
||||||
|
TUSD_BEHIND_PROXY_FLAG="--behind-proxy"
|
||||||
|
echo "[INFO] TUSD_BEHIND_PROXY=true, enabling tusd proxy mode"
|
||||||
|
fi
|
||||||
|
tusd \
|
||||||
|
--base-path /tus/files/ \
|
||||||
|
--upload-dir /data/private/uploads/tmp \
|
||||||
|
--hooks-http http://127.0.0.1:3000/api/tus/hooks \
|
||||||
|
--port 1080 \
|
||||||
|
--max-size 21474836480 \
|
||||||
|
$TUSD_BEHIND_PROXY_FLAG &
|
||||||
|
|
||||||
|
echo "▶ Starting Next.js server..."
|
||||||
|
PORT=3000 node server.js &
|
||||||
|
|
||||||
|
echo "▶ Starting nginx..."
|
||||||
|
exec nginx -g "daemon off;"
|
||||||
|
|
||||||
exec "$@"
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
events {}
|
||||||
|
|
||||||
|
http {
|
||||||
|
map $http_x_forwarded_proto $forwarded_proto {
|
||||||
|
default $scheme;
|
||||||
|
"~*^\s*(https?)\s*(?:,|$)" $1;
|
||||||
|
}
|
||||||
|
|
||||||
|
client_max_body_size 20G;
|
||||||
|
ignore_invalid_headers off;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
|
||||||
|
location /tus/ {
|
||||||
|
proxy_pass http://127.0.0.1:1080/tus/;
|
||||||
|
|
||||||
|
proxy_pass_request_headers on;
|
||||||
|
|
||||||
|
proxy_request_buffering off;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Forwarded-Host $http_host;
|
||||||
|
proxy_set_header X-Forwarded-Proto $forwarded_proto;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Forwarded-Host $http_host;
|
||||||
|
proxy_set_header X-Forwarded-Proto $forwarded_proto;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
.DS_Store
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
.bzr/
|
||||||
|
.bzrignore
|
||||||
|
.hg/
|
||||||
|
.hgignore
|
||||||
|
.svn/
|
||||||
|
*.swp
|
||||||
|
*.bak
|
||||||
|
*.tmp
|
||||||
|
*.orig
|
||||||
|
*~
|
||||||
|
.project
|
||||||
|
.idea/
|
||||||
|
*.tmproj
|
||||||
|
.vscode/
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
apiVersion: v2
|
||||||
|
name: portabase
|
||||||
|
description: Helm chart for Portabase
|
||||||
|
type: application
|
||||||
|
version: 0.0.0
|
||||||
|
appVersion: "latest"
|
||||||
|
keywords:
|
||||||
|
- postgresql
|
||||||
|
- mariadb
|
||||||
|
- mongodb
|
||||||
|
- mysql
|
||||||
|
- sqlite
|
||||||
|
- backup
|
||||||
|
- database
|
||||||
|
- restore
|
||||||
|
home: https://github.com/Portabase/portabase
|
||||||
|
|
||||||
|
sources:
|
||||||
|
- https://github.com/Portabase/portabase
|
||||||
|
- https://github.com/Portabase/portabase/tree/main/helm
|
||||||
|
|
||||||
|
maintainers:
|
||||||
|
- name: Charles Gauthereau
|
||||||
|
url: https://github.com/RambokDev
|
||||||
|
- name: Killian Larcher
|
||||||
|
url: https://github.com/KillianLarcher
|
||||||
|
|
||||||
|
icon: https://raw.githubusercontent.com/Portabase/portabase/main/.github/assets/logo.png
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user