Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b3c177909 | ||
|
|
c486885159 | ||
|
|
fc0be1dcd1 | ||
|
|
f12cf3725a | ||
|
|
23fb84db76 | ||
|
|
505ee42867 | ||
|
|
41bf189e6b | ||
|
|
1c3ab1fd1c | ||
|
|
5b2d4e4454 | ||
|
|
06212b4c34 | ||
|
|
79bd9c2f4e | ||
|
|
d7ecb5494d | ||
|
|
06be755d56 | ||
|
|
b2b0094b5e | ||
|
|
96bd15b3e2 | ||
|
|
f3353d9007 | ||
|
|
a864cbcee8 | ||
|
|
da671e67cc | ||
|
|
5afecfe52c | ||
|
|
fed842a44d | ||
|
|
16a897e65d | ||
|
|
e4530caab6 | ||
|
|
6bd8d0f9a7 | ||
|
|
239037b4c6 | ||
|
|
12a4bf70c0 | ||
|
|
1897bef08d | ||
|
|
aeb9dc24ad | ||
|
|
bcba666507 | ||
|
|
b31e7f1ea7 | ||
|
|
837e25be74 | ||
|
|
4e3e255575 | ||
|
|
362ecb0bc7 | ||
|
|
0cd61093b9 | ||
|
|
69233cd769 | ||
|
|
0b9e7cdb4e | ||
|
|
afe431c94d | ||
|
|
e0a748812d | ||
|
|
40474d3990 | ||
|
|
41d970e4a7 | ||
|
|
044367b979 | ||
|
|
515f3123dc | ||
|
|
81b7c1c6aa | ||
|
|
0dfec506cf | ||
|
|
4c40eef99a | ||
|
|
7557ac854e | ||
|
|
0008842a68 | ||
|
|
1754274d46 | ||
|
|
dc2b8f7c5e | ||
|
|
d08dbf34aa | ||
|
|
adf1e4e9ea | ||
|
|
138fd8af09 | ||
|
|
38051b9465 | ||
|
|
bbe9a97d05 | ||
|
|
2c745fa68d | ||
|
|
88557bfaf5 | ||
|
|
829658ae4d | ||
|
|
f48cc5f007 | ||
|
|
f4b3091666 | ||
|
|
66fbc730fe | ||
|
|
2f9d737515 | ||
|
|
dda70352c0 | ||
|
|
dbd3b26f6c | ||
|
|
e6f8153b34 | ||
|
|
97e41fbfe5 | ||
|
|
3e1a93296e | ||
|
|
e188dd5850 | ||
|
|
be81fdab41 | ||
|
|
5228eca9ae | ||
|
|
e3d69d3cfa | ||
|
|
450badfbbb | ||
|
|
1cd79005c3 | ||
|
|
443208b217 | ||
|
|
6b0abf1edc | ||
|
|
7ca17fdd2c | ||
|
|
098b24c276 | ||
|
|
85b76960c3 | ||
|
|
965f1f6eae | ||
|
|
9b086365d0 | ||
|
|
fbc228eb6c | ||
|
|
97497b12f5 | ||
|
|
695a99dade | ||
|
|
be9a564874 | ||
|
|
e6c5075f7c | ||
|
|
d3ff23ddfe | ||
|
|
bf6b2f8dda | ||
|
|
3cec2c5c66 | ||
|
|
18f8f35672 | ||
|
|
1b42caf3a7 | ||
|
|
96f82201b5 | ||
|
|
606227d308 | ||
|
|
96625916e2 | ||
|
|
a48f01992f | ||
|
|
f1f4edf247 | ||
|
|
e5480621ee | ||
|
|
07d0010750 | ||
|
|
16726e2461 | ||
|
|
0a133dc395 | ||
|
|
73dfcc46c3 | ||
|
|
bc54c95140 | ||
|
|
29a6a0df8c | ||
|
|
9074d4bfb8 | ||
|
|
d8ee50ece6 | ||
|
|
f493d0d04d | ||
|
|
e7ac416440 | ||
|
|
e65ced70ff | ||
|
|
f19f165afc | ||
|
|
f9e376a117 | ||
|
|
dafda4a977 | ||
|
|
9b8cbb05e3 | ||
|
|
6c3bd96606 | ||
|
|
72ecd20dd1 | ||
|
|
c996d71378 | ||
|
|
1eb86060b5 | ||
|
|
18f2dcbd7d | ||
|
|
9639fa8db7 | ||
|
|
c08d8cf617 | ||
|
|
f6bd4e3a35 | ||
|
|
e13c6d0d1a | ||
|
|
2a4434a04a | ||
|
|
dd1fb90355 | ||
|
|
84bf1f091b | ||
|
|
c103eed463 | ||
|
|
e1aa717b22 | ||
|
|
ee6898f243 | ||
|
|
a2a5415ace | ||
|
|
b1023cdcfd | ||
|
|
fa0efb1597 | ||
|
|
0a7de05892 | ||
|
|
e5281751c1 | ||
|
|
c2fbfd2e6c | ||
|
|
c3ce8cfc3b | ||
|
|
3c6d98fa11 | ||
|
|
a5143ad9d3 | ||
|
|
9bfec11b48 | ||
|
|
580c1093d1 | ||
|
|
c706523bd0 | ||
|
|
685c222b9d | ||
|
|
added3bb42 | ||
|
|
6fdf9261da | ||
|
|
13091c2a7c | ||
|
|
1f65fec9ea | ||
|
|
9a75243732 | ||
|
|
2d6ec5dde7 | ||
|
|
fae23dc57c | ||
|
|
4607c1bb99 | ||
|
|
781469c4cf | ||
|
|
ae3b74ad2a | ||
|
|
193073727a | ||
|
|
9aa3a5a84e | ||
|
|
7b8b9f5c80 | ||
|
|
28229c4fc7 | ||
|
|
db2a7a8aa5 | ||
|
|
89f84367ad | ||
|
|
4c096e8774 | ||
|
|
5803295174 | ||
|
|
ccf901d5c7 | ||
|
|
374dc600e0 | ||
|
|
8171907ec2 | ||
|
|
98f328a3fa | ||
|
|
3b4d0777a1 | ||
|
|
e2f5e06c07 | ||
|
|
0794c642e4 | ||
|
|
4c496feff2 | ||
|
|
7718c249d0 | ||
|
|
17e95601ec | ||
|
|
af79994595 | ||
|
|
706caa44bf | ||
|
|
1323012cff | ||
|
|
ccfa3497f3 | ||
|
|
b1b50045a5 | ||
|
|
cc1666b16a | ||
|
|
be6ef48d40 | ||
|
|
7ec1e01edd | ||
|
|
d44d1e77e8 | ||
|
|
206f6dd909 | ||
|
|
c109f25fae | ||
|
|
eab61f85b9 | ||
|
|
0252540c4b | ||
|
|
46935a7a12 | ||
|
|
360d7d87c5 | ||
|
|
01d4e9b9c6 | ||
|
|
218800395d | ||
|
|
46d84f4b2c | ||
|
|
3455a89fd5 | ||
|
|
49b6db19eb | ||
|
|
4de356cb1d | ||
|
|
9d1a3fd533 | ||
|
|
8284c41d6e | ||
|
|
cef4ab7eaa | ||
|
|
b5abd7555f | ||
|
|
ceece29737 | ||
|
|
4adba368c4 | ||
|
|
8e2c826ba3 | ||
|
|
68e6f2d117 | ||
|
|
4a3145bfbd | ||
|
|
21e8493838 | ||
|
|
a3f0de7916 | ||
|
|
64c5ca5cb1 | ||
|
|
70069e4e23 | ||
|
|
4066de695d | ||
|
|
31f6261c1c | ||
|
|
e864b5f275 | ||
|
|
86b1ea887c | ||
|
|
93d51c1feb | ||
|
|
ce6a777770 | ||
|
|
440ae19c96 | ||
|
|
7f66e3e523 | ||
|
|
f61c1c45d5 | ||
|
|
707fea6d13 | ||
|
|
ffd46c256c | ||
|
|
e1fc9ddcef | ||
|
|
d3da40e0ad | ||
|
|
3d14d42356 | ||
|
|
27a83bdf9e | ||
|
|
a5f9df34fb | ||
|
|
92dbbbd5e8 | ||
|
|
111b1720b4 | ||
|
|
02ddf85af2 | ||
|
|
82b2918963 | ||
|
|
368b60b7e6 | ||
|
|
d9586c5200 | ||
|
|
136206fcff | ||
|
|
678a6fd7cb | ||
|
|
16e3cc3998 | ||
|
|
3e4e9c30c9 | ||
|
|
9aa52aa042 | ||
|
|
fb83d9635b | ||
|
|
e112e21891 | ||
|
|
6025b7afb2 | ||
|
|
dc390b552d | ||
|
|
9fabb602bf | ||
|
|
12405d5106 | ||
|
|
e2217e5f71 | ||
|
|
ce4aea8da1 | ||
|
|
9c7f566d4d | ||
|
|
cc35a754e0 | ||
|
|
c9a6065867 | ||
|
|
9d0a0e1db1 | ||
|
|
61fc975ac9 | ||
|
|
43a2945208 | ||
|
|
0d8502f799 | ||
|
|
5d2ec70476 | ||
|
|
e54030dee9 | ||
|
|
e5c53d598c | ||
|
|
07a9b00710 | ||
|
|
8b1f234b91 | ||
|
|
a21ff40588 | ||
|
|
d833df8b0d | ||
|
|
bc95e8dced | ||
|
|
9afc152f25 | ||
|
|
d9ea14e37f | ||
|
|
8428d268b1 | ||
|
|
7475f398b1 | ||
|
|
9e64bb5eec | ||
|
|
994ceca1d1 | ||
|
|
f2ab51a314 | ||
|
|
059764d015 | ||
|
|
abf6acf82e | ||
|
|
50a9a87a6a | ||
|
|
7a6450091a | ||
|
|
c4db1973c4 | ||
|
|
b6f01da232 | ||
|
|
d5e4a10137 | ||
|
|
37c861cc81 | ||
|
|
1e4a09d8b5 | ||
|
|
27ace7cafd | ||
|
|
0bdf8b3498 | ||
|
|
0fc6ab8636 | ||
|
|
5bb339e1a2 | ||
|
|
1334e48fc7 | ||
|
|
25af34aa6a | ||
|
|
c830a55a64 | ||
|
|
7365dbdb11 | ||
|
|
f31143ba3a | ||
|
|
5a50155cd3 | ||
|
|
6843ca966b | ||
|
|
06a9d8015c | ||
|
|
5c3b21d711 | ||
|
|
416cae3101 | ||
|
|
819b13fe55 | ||
|
|
15c21b6379 | ||
|
|
2b6d955764 | ||
|
|
60337027dc | ||
|
|
2a0fd54855 | ||
|
|
b83f52707a | ||
|
|
5bb6378e94 | ||
|
|
40eb74529e | ||
|
|
9f29b10098 | ||
|
|
d3aaf8f5db | ||
|
|
26cd70cbaa | ||
|
|
65aae5ec95 | ||
|
|
5af4332593 | ||
|
|
12717f15cc | ||
|
|
dc1009cdd8 | ||
|
|
52bda5f823 | ||
|
|
44b6932202 | ||
|
|
0556d8b69d | ||
|
|
01516e030b | ||
|
|
c32d68809b | ||
|
|
d48172986e | ||
|
|
e6f6dbbddd | ||
|
|
7995a588bd | ||
|
|
baa587166c | ||
|
|
8dab66567d | ||
|
|
a7959ad0fa | ||
|
|
25304f8229 | ||
|
|
bc0602c523 | ||
|
|
ae60604ef1 | ||
|
|
4bdcfbd5b1 | ||
|
|
53c02d8914 | ||
|
|
4d74052ad8 | ||
|
|
435a67cb5d | ||
|
|
aadee6ac8a | ||
|
|
9c07ac356b | ||
|
|
9a3c79d93d | ||
|
|
2bd9f39da5 | ||
|
|
c4e63dcc85 | ||
|
|
794e5a22c1 | ||
|
|
eeb5245cb9 | ||
|
|
e8328b25bc | ||
|
|
9c1ce91de1 | ||
|
|
073bcea56f | ||
|
|
9e3872c980 | ||
|
|
658d14314b | ||
|
|
107a3ac6d4 | ||
|
|
ad82841eb2 | ||
|
|
c9d94b54d7 | ||
|
|
d96ddf9b18 | ||
|
|
7917c2ad35 | ||
|
|
2db5ce544f | ||
|
|
474b99c6d5 | ||
|
|
aded63f76f | ||
|
|
d1a7e6ecfe | ||
|
|
a5b8e02a98 | ||
|
|
72af00f1b7 | ||
|
|
b86a849e00 | ||
|
|
09c3a1862a | ||
|
|
54da27ef70 | ||
|
|
d9728cbb7a | ||
|
|
420478ce6e | ||
|
|
21d2893292 | ||
|
|
1189bdef33 | ||
|
|
15d2e7917a | ||
|
|
903a265676 | ||
|
|
965cf754cf | ||
|
|
3850eedf07 | ||
|
|
0b400b1ffd | ||
|
|
abb143601b | ||
|
|
4d193f0dd6 | ||
|
|
9ca959c31c | ||
|
|
29d4b276e2 | ||
|
|
6ed3d5b501 | ||
|
|
14037ddad2 | ||
|
|
1ecf18e41c | ||
|
|
badcf700b9 | ||
|
|
527c680e1f | ||
|
|
174b2ffdbb | ||
|
|
b4e2f2f13e | ||
|
|
fb7676ab4e | ||
|
|
c9018e843e | ||
|
|
31db9658e0 | ||
|
|
449254bcfd | ||
|
|
fc380df8dd | ||
|
|
dc4e8598f7 | ||
|
|
b0151ee985 | ||
|
|
b9eba7bfa4 | ||
|
|
cad6c39e72 | ||
|
|
2e64b56d14 | ||
|
|
1620f2be56 | ||
|
|
f6fa44b7fc | ||
|
|
ab33472bf4 | ||
|
|
5ad15326ec | ||
|
|
86c301bcf9 | ||
|
|
7a52df61ec | ||
|
|
0c40f1907e | ||
|
|
35641e5957 | ||
|
|
82130ecce0 | ||
|
|
ad81f666a5 | ||
|
|
847f47c5d3 | ||
|
|
aefcf81bb3 | ||
|
|
e3b2d08021 | ||
|
|
3da2af9111 | ||
|
|
a6eade1ae1 | ||
|
|
3b3d739e91 | ||
|
|
9bb3afe6ce | ||
|
|
41a99be4ca | ||
|
|
808f6eea33 | ||
|
|
974570c79e | ||
|
|
5ba53d3cbd | ||
|
|
8748f07167 | ||
|
|
d603b293d8 | ||
|
|
2ce5e9a1e2 | ||
|
|
108754a8c3 | ||
|
|
8ea8960696 | ||
|
|
1cbcb73948 | ||
|
|
e68c7e2c61 | ||
|
|
e09f892442 | ||
|
|
56f3b45a4e | ||
|
|
77770112bd | ||
|
|
b18af0b907 | ||
|
|
da98261d27 | ||
|
|
9fae7c06af | ||
|
|
b29c013706 | ||
|
|
0b095f43bb | ||
|
|
e2be689a05 | ||
|
|
7dce0fb4f4 | ||
|
|
a66cf92bd5 | ||
|
|
5c8bdf04a1 | ||
|
|
315bf650d4 | ||
|
|
f2579a7b37 | ||
|
|
319b88cedf | ||
|
|
9af721176b | ||
|
|
113e2f27fb | ||
|
|
7b3e99b599 | ||
|
|
2398035e53 | ||
|
|
d153d47604 | ||
|
|
a02c813784 | ||
|
|
d72f1c85c1 | ||
|
|
62fc8a1054 | ||
|
|
e4b1c60ce5 | ||
|
|
b620d8f0f2 | ||
|
|
47500bf92e | ||
|
|
6938eb5f35 | ||
|
|
8936adbe1c | ||
|
|
121f278713 | ||
|
|
0a0b07a255 | ||
|
|
f44b436bc1 | ||
|
|
75ab487031 | ||
|
|
133c936d7b | ||
|
|
b0146a5a76 | ||
|
|
9c0d887a3c | ||
|
|
2b2c76bde9 | ||
|
|
43fe84de3a | ||
|
|
318c33e8ff | ||
|
|
c32784e8ae | ||
|
|
898cdd2d65 | ||
|
|
b441151e09 | ||
|
|
0ea173ffe5 | ||
|
|
4b1d0a614c | ||
|
|
02d598f406 | ||
|
|
ae11e469d2 | ||
|
|
4f2fac11d3 | ||
|
|
68bc907bd2 | ||
|
|
d7e5805024 | ||
|
|
97e31b6c47 | ||
|
|
cb67ef8258 | ||
|
|
dc3d7303d7 | ||
|
|
cdb74c6a19 | ||
|
|
cf432e32f7 | ||
|
|
c3435e2ece | ||
|
|
b52394a166 | ||
|
|
d65f8eb0f5 | ||
|
|
86acd842b0 | ||
|
|
69b89a0c2a | ||
|
|
2992dd43e8 | ||
|
|
cb4ecb49eb | ||
|
|
d51294ce54 | ||
|
|
32eb35f7b2 | ||
|
|
80717e6c69 | ||
|
|
d123f8fc07 | ||
|
|
2dcaf4a3a1 | ||
|
|
c4cdacc2d8 | ||
|
|
06afa786ef | ||
|
|
b8fccab33a | ||
|
|
ec5a12b0f7 | ||
|
|
7b6c5f74d4 | ||
|
|
02df9b519f | ||
|
|
38a3f01868 | ||
|
|
a120b58dc2 | ||
|
|
33be6b036d | ||
|
|
93211cf073 | ||
|
|
046022221e | ||
|
|
fc01b1db81 | ||
|
|
ed7557c5da | ||
|
|
5d110da392 | ||
|
|
ab443f58d0 | ||
|
|
d4dde3fe4f | ||
|
|
3067b06bf6 | ||
|
|
0e2926cc85 | ||
|
|
72b9eb443c | ||
|
|
4ad70d5392 | ||
|
|
d8ce8231cc | ||
|
|
7381ff8589 | ||
|
|
277e2d6cd8 | ||
|
|
58ae69de0b | ||
|
|
f73f812cf1 | ||
|
|
d1eba4a089 | ||
|
|
502c13c3ce | ||
|
|
ee9878ac25 | ||
|
|
4cacfc253c | ||
|
|
b6167ce422 | ||
|
|
b1dd4b5308 | ||
|
|
e7a7e02626 | ||
|
|
5e22795828 | ||
|
|
bf423658af | ||
|
|
8bd6f08794 | ||
|
|
62d88121cd | ||
|
|
85e38c390c | ||
|
|
10b6e69dec | ||
|
|
5d1cc211b0 | ||
|
|
83598f4013 | ||
|
|
76eaa3bc4b | ||
|
|
92ec968d5e | ||
|
|
049aa4ec44 | ||
|
|
869fd8c6bc | ||
|
|
d175b0af34 | ||
|
|
0aff72059b | ||
|
|
168b33d5d5 | ||
|
|
ccb84f585a | ||
|
|
39e7b3f615 | ||
|
|
73816ff2d7 | ||
|
|
f5f595ac02 | ||
|
|
c0a4e7add1 | ||
|
|
26ea9fc1b8 | ||
|
|
3c515fcef3 | ||
|
|
e771d6f49e | ||
|
|
003a661c08 | ||
|
|
28b0840e69 | ||
|
|
81af2a9a27 | ||
|
|
206416889c | ||
|
|
6ab30ed308 | ||
|
|
3a9bb58646 | ||
|
|
af7e504e4f | ||
|
|
2178a507c5 | ||
|
|
7832b5b8b8 | ||
|
|
d786d0018e | ||
|
|
a9efb88fe2 | ||
|
|
e44e66a8e8 | ||
|
|
7d81998577 | ||
|
|
5e748d0cef | ||
|
|
4f90561c41 | ||
|
|
dc57d5d15f | ||
|
|
2d03aa02e2 | ||
|
|
8d4d564935 | ||
|
|
e710dbf720 | ||
|
|
aba81334a5 | ||
|
|
c53c974713 | ||
|
|
574f642200 | ||
|
|
fd6e4cc189 | ||
|
|
c8b90d1286 | ||
|
|
cbf50299ad | ||
|
|
26b2cdc194 | ||
|
|
00ee7084a6 | ||
|
|
75dc1c1548 | ||
|
|
943963f760 | ||
|
|
03d3c35a86 | ||
|
|
3ed194b7bc | ||
|
|
c764d53b88 | ||
|
|
ccc5eddf92 | ||
|
|
1ff2f7d7ba | ||
|
|
c3f518e5b7 | ||
|
|
84cfef8480 | ||
|
|
bf14066aba | ||
|
|
c592d0063d | ||
|
|
e9787440fb | ||
|
|
1ee02d1451 | ||
|
|
2378ba894d | ||
|
|
0fd652f716 | ||
|
|
c4a33206ce | ||
|
|
6289e479a5 | ||
|
|
7d97f6424b | ||
|
|
383d4049e0 | ||
|
|
5fc844b840 | ||
|
|
4833a76b05 | ||
|
|
f594dd77fd | ||
|
|
f99afad7f6 | ||
|
|
1c327ac252 | ||
|
|
7556726e08 | ||
|
|
92729f26df | ||
|
|
701e536a90 | ||
|
|
1f45a95710 | ||
|
|
9f7f83cfd2 | ||
|
|
36fd4871e1 | ||
|
|
7882abea35 | ||
|
|
67fbadabf2 | ||
|
|
a4f2553fbc | ||
|
|
4704a8a234 | ||
|
|
ee547c5899 | ||
|
|
9b1d915dd1 | ||
|
|
f6f40ab707 | ||
|
|
33f3e20849 | ||
|
|
4fcb946f0f | ||
|
|
12976024b7 | ||
|
|
1f7367edb1 | ||
|
|
0493d77f46 | ||
|
|
4af979ac9b | ||
|
|
c28695fa18 | ||
|
|
5ffd6d612b | ||
|
|
577edd86f4 | ||
|
|
412089948c | ||
|
|
65773364a1 | ||
|
|
91bc5d0d5e | ||
|
|
b580b2456a | ||
|
|
7e27cfe5f8 | ||
|
|
4609a3d5f3 | ||
|
|
20a5646a53 | ||
|
|
0510634db7 | ||
|
|
59018287de | ||
|
|
e1edc5b550 | ||
|
|
d74bc06cbd | ||
|
|
fedde804e3 | ||
|
|
7467553bf2 | ||
|
|
9f8f45db5a | ||
|
|
b1fdbf714b | ||
|
|
68db934507 | ||
|
|
1b4902f196 | ||
|
|
61dfe67ca8 | ||
|
|
038cb620c6 | ||
|
|
0e88e3e1f4 | ||
|
|
f4fb5744ef | ||
|
|
474c439076 | ||
|
|
9cb75ec608 | ||
|
|
560308fd31 | ||
|
|
da9bdb8dcd | ||
|
|
b3723e260f | ||
|
|
c6bc631327 | ||
|
|
ec013488ff | ||
|
|
58e5609b59 | ||
|
|
00c2b8262b | ||
|
|
172f9a82d0 | ||
|
|
72325a6cbc | ||
|
|
a3c341b5b4 | ||
|
|
7d08017c7f | ||
|
|
07ee8448e6 | ||
|
|
20da36416c | ||
|
|
97b612c1b3 | ||
|
|
01a69b9bf9 | ||
|
|
d9cb536518 | ||
|
|
887acd5a55 | ||
|
|
da0916b8c7 | ||
|
|
8f90ec4173 | ||
|
|
2d2bd1d0b1 | ||
|
|
661fed0dbb | ||
|
|
e3e1d7b012 | ||
|
|
1e88d3dc5f | ||
|
|
b943f5a51d | ||
|
|
5b8937ac3a | ||
|
|
d0d84a62cd | ||
|
|
7aa7b5526b | ||
|
|
812aff9686 | ||
|
|
23e6b02ae4 | ||
|
|
2f69538576 | ||
|
|
e52782fe9c | ||
|
|
376d2283bf | ||
|
|
3c3c61fee2 | ||
|
|
b6a7d8a54a | ||
|
|
9f4c803495 | ||
|
|
d4baaaa0ff | ||
|
|
f3bc35ad35 | ||
|
|
ae8b87fb18 | ||
|
|
f0406cac8e | ||
|
|
12dcc45e88 | ||
|
|
1f39c2e229 | ||
|
|
9430d734a7 | ||
|
|
2ca5463d1d | ||
|
|
a9fe99b09b | ||
|
|
dd71d1c49e | ||
|
|
ee93fbb120 | ||
|
|
ce96579d26 | ||
|
|
3ae6392a82 | ||
|
|
ac16a94a92 | ||
|
|
65f5bb2daf | ||
|
|
22810a96b7 | ||
|
|
638fd6234e | ||
|
|
838b275c19 | ||
|
|
6945843c97 | ||
|
|
6d53606b22 | ||
|
|
789719ada6 | ||
|
|
7014ab4d01 | ||
|
|
790833dc4e | ||
|
|
f575441aa6 | ||
|
|
e4b13b753b | ||
|
|
76622dc32e | ||
|
|
80f2641bae | ||
|
|
186f132262 | ||
|
|
00a1ebaf25 | ||
|
|
09389e2ee4 | ||
|
|
5d2412bbb0 | ||
|
|
174a74023d | ||
|
|
d607a76097 | ||
|
|
34056c1c66 | ||
|
|
c58138b4cc | ||
|
|
1d50e2f0bf | ||
|
|
c57b477dc9 | ||
|
|
86f63eecc5 | ||
|
|
ff25927b52 | ||
|
|
141e1a938d | ||
|
|
bab4f304c3 | ||
|
|
5ecb66a4ef | ||
|
|
5ae5907d11 | ||
|
|
24c5ecc437 | ||
|
|
0a144a633d | ||
|
|
f308d1cfde | ||
|
|
3f4057b17c | ||
|
|
4b4f778360 | ||
|
|
6edc384c05 | ||
|
|
540613645f | ||
|
|
586605557e | ||
|
|
f08c9d36a6 | ||
|
|
da43314332 | ||
|
|
ccd5455103 | ||
|
|
d3d408bc68 | ||
|
|
a9bafda41c | ||
|
|
3d1fd4a7aa | ||
|
|
951bec8596 | ||
|
|
5e47e8bd29 | ||
|
|
d53c94eb91 | ||
|
|
111d2cecf9 | ||
|
|
eccb430a9b | ||
|
|
bd207a3551 | ||
|
|
d3f12802a3 | ||
|
|
218394fefc | ||
|
|
f036d41559 | ||
|
|
ac5c18e261 | ||
|
|
e3f5cab6dd | ||
|
|
9bfaae7194 | ||
|
|
4844270f0b | ||
|
|
3167d96cfc | ||
|
|
84f1bd95e7 | ||
|
|
9766bb2ed2 | ||
|
|
3af20f9327 | ||
|
|
983f285373 | ||
|
|
f0f447857f | ||
|
|
fb66acebc3 | ||
|
|
508e49e986 | ||
|
|
44fe84a58a | ||
|
|
0bccd3a801 | ||
|
|
2721a90455 | ||
|
|
52bd1ed1d4 | ||
|
|
68c2b4d084 | ||
|
|
ab94dd5b86 | ||
|
|
556e7d445a | ||
|
|
fbaa61bbbc | ||
|
|
8f4c5aab0f | ||
|
|
a4084cb556 | ||
|
|
9ffa05affb | ||
|
|
07d390b72e | ||
|
|
5690518f25 | ||
|
|
79b2d46849 | ||
|
|
9c7eaf63d5 | ||
|
|
0ce796e1a4 | ||
|
|
b5203b5c0d | ||
|
|
b14b1cd60d | ||
|
|
0c0d0ea3a3 | ||
|
|
d12b686e52 | ||
|
|
73185940b6 | ||
|
|
f3920afe8c | ||
|
|
ad1994574d | ||
|
|
d772ae0b31 | ||
|
|
ed94244741 | ||
|
|
73cb979721 | ||
|
|
b5d6368cca | ||
|
|
be3b954716 | ||
|
|
1d05cfa0af | ||
|
|
38c971ab49 | ||
|
|
61523e33df | ||
|
|
9b5764d4fb | ||
|
|
1bc824e39b | ||
|
|
e662e907be | ||
|
|
28178e46de | ||
|
|
3acc6febae | ||
|
|
fbb8ffbfc6 | ||
|
|
581a2f164b | ||
|
|
051d7d6eb4 | ||
|
|
7766c3bb66 | ||
|
|
35aa91de27 | ||
|
|
3a793ed87d | ||
|
|
9cfdf4a194 | ||
|
|
aa0ffedc8c | ||
|
|
82c162d399 | ||
|
|
995d52db1b | ||
|
|
c7e58f0828 | ||
|
|
1f99576a37 | ||
|
|
91d8d3ee83 | ||
|
|
5a92c5508c | ||
|
|
73a97893e9 | ||
|
|
bc1f876fb5 | ||
|
|
eeb0fdf3a4 | ||
|
|
4fb24cb229 | ||
|
|
d35b3661a9 | ||
|
|
3e6e223ed0 | ||
|
|
8579edf1ca | ||
|
|
420d85e811 | ||
|
|
924a46894b | ||
|
|
71aec1f07a | ||
|
|
4688e62b90 | ||
|
|
232790b452 | ||
|
|
4c1bb758ce | ||
|
|
6749b2ace8 | ||
|
|
8f7fdcbdca | ||
|
|
1e50471a87 | ||
|
|
20a5728152 | ||
|
|
3170fb22cb | ||
|
|
c02d15ec34 | ||
|
|
2a4edaf08f | ||
|
|
639ca96caa | ||
|
|
fb13ce8b07 | ||
|
|
2aacab44ec | ||
|
|
279acc8af8 | ||
|
|
3765728342 | ||
|
|
68ceb212b5 | ||
|
|
e52114e969 | ||
|
|
e3b7dd013b | ||
|
|
cea3b6b43e | ||
|
|
06d39e0ab7 | ||
|
|
785108002e | ||
|
|
c12853d0b0 | ||
|
|
f1afa0ac33 | ||
|
|
5265016003 | ||
|
|
c6cb3db624 | ||
|
|
d35f58a2b5 | ||
|
|
d9ada2e58a | ||
|
|
4d6c688bf0 | ||
|
|
3eec0947d2 | ||
|
|
5979272115 | ||
|
|
5fb70ace3c | ||
|
|
5e4398ceae | ||
|
|
67a9820c98 | ||
|
|
665f79d26c | ||
|
|
6152819e45 | ||
|
|
427a08029a | ||
|
|
df7ef6d8f0 | ||
|
|
b9b646bb11 | ||
|
|
ae62f8ea3d | ||
|
|
0939632399 | ||
|
|
4b5a2b96a8 | ||
|
|
79bf430dee | ||
|
|
62a168d44f | ||
|
|
502c8dae91 | ||
|
|
afdfc54802 | ||
|
|
651b65a882 | ||
|
|
c2290acae3 | ||
|
|
e1276afd71 | ||
|
|
dea0fb8b78 | ||
|
|
faebfb3e62 | ||
|
|
6fd7b7d427 | ||
|
|
385b415676 | ||
|
|
50f0f5f6dc | ||
|
|
8889847c71 | ||
|
|
d26bb75ebc | ||
|
|
a65cca44e5 | ||
|
|
18b0260d1f | ||
|
|
c9d4ef5e89 | ||
|
|
c0f3f027ae | ||
|
|
0bd5360315 | ||
|
|
29b46ba59c | ||
|
|
04fe0023b7 | ||
|
|
cb3c3e0e2e | ||
|
|
0ac9d72deb | ||
|
|
dd2200bdb8 | ||
|
|
ca894c621f | ||
|
|
4224b3d326 | ||
|
|
891f45c1ca | ||
|
|
595f51d76c | ||
|
|
50cdf99913 | ||
|
|
e9f2f8eb83 | ||
|
|
73d2aa8fe1 | ||
|
|
579eaf9141 | ||
|
|
c513930e27 | ||
|
|
7a75447364 | ||
|
|
ec6bb5590d | ||
|
|
637e7a4e24 | ||
|
|
08a70baf3f | ||
|
|
bc43ca6f0f | ||
|
|
65ba084d80 | ||
|
|
961ad31c13 | ||
|
|
40509cc5fd | ||
|
|
7606a88293 | ||
|
|
d3beb57f15 | ||
|
|
691106326f | ||
|
|
c82205ce4f | ||
|
|
f0552ab72d | ||
|
|
786081aafa | ||
|
|
0251a8bb73 | ||
|
|
2a78500fdf | ||
|
|
f68d81ff17 | ||
|
|
38ecb5ffc4 | ||
|
|
fa8f3a2997 | ||
|
|
00f4413f12 | ||
|
|
d4d97edeef | ||
|
|
14cc8d5c24 | ||
|
|
cc983f9ad6 | ||
|
|
0764b9263d | ||
|
|
a207b66b7e | ||
|
|
d42bd2e803 | ||
|
|
27b3cac1e0 | ||
|
|
866cdccd49 | ||
|
|
1e58c94598 | ||
|
|
96bcaa5baa | ||
|
|
fe9d7e459d | ||
|
|
3f21160545 | ||
|
|
e4e7287441 | ||
|
|
f78c83188f | ||
|
|
a57c9e79d9 | ||
|
|
772e854aa6 | ||
|
|
717bc0d919 | ||
|
|
c64d58cee9 | ||
|
|
a5dc06f217 | ||
|
|
631ef974b6 | ||
|
|
a749d6ce13 | ||
|
|
1bd5e1f04b | ||
|
|
5467b07823 | ||
|
|
ead8f0e449 | ||
|
|
0860a6b704 | ||
|
|
573737a9ae | ||
|
|
076c40e624 | ||
|
|
dc60ae89da | ||
|
|
d68bea75f0 | ||
|
|
9968b088ea | ||
|
|
bc47ccc818 | ||
|
|
96f8389751 | ||
|
|
1c9d7807cf | ||
|
|
c9e88c86be | ||
|
|
10ad49b766 | ||
|
|
1ba7bee5bf | ||
|
|
c6793cc454 | ||
|
|
691ebfbefb | ||
|
|
c096891efa | ||
|
|
22f0b674e2 | ||
|
|
d8cf69c8e9 | ||
|
|
e27ad3f9b1 | ||
|
|
da58a9b0a6 | ||
|
|
165f411083 | ||
|
|
0bb942857a | ||
|
|
58a9c2b2bf | ||
|
|
8b3c5bd8e9 | ||
|
|
f545746e7e | ||
|
|
1123f24fb8 | ||
|
|
9b0b71363f | ||
|
|
2e5a006846 | ||
|
|
72df273e10 | ||
|
|
7b8559e32a | ||
|
|
002f61d570 | ||
|
|
a72ade041b | ||
|
|
0cf0ecc5e8 | ||
|
|
42ef52d271 | ||
|
|
e6a2af1cd4 | ||
|
|
8b2ff2b57d | ||
|
|
23aca09afc | ||
|
|
00fa57d9c4 | ||
|
|
5f7b18fe0e | ||
|
|
fc5d7c2045 | ||
|
|
e5ef5b93e1 | ||
|
|
081f0f7ebc | ||
|
|
fadfaeb547 | ||
|
|
95c804b5b5 | ||
|
|
6f4924878a | ||
|
|
83429723ce | ||
|
|
a9c482a7c6 | ||
|
|
dada1ab0c1 | ||
|
|
7859870646 | ||
|
|
58ee238db4 | ||
|
|
09ad707d94 | ||
|
|
dfefaded1f | ||
|
|
6585c3be40 | ||
|
|
d0a6190887 | ||
|
|
73ee7ca95a | ||
|
|
d904ea9a29 | ||
|
|
e0dd4c6a6f | ||
|
|
7c85c867de | ||
|
|
2bc16d6c1b | ||
|
|
d93ec7e56d | ||
|
|
de22b80203 | ||
|
|
4086215a03 | ||
|
|
fb2c7ed00a | ||
|
|
1006cbc9a7 | ||
|
|
64b074881e | ||
|
|
10e5cfce49 | ||
|
|
f2e6be81d2 | ||
|
|
6ceb5fc5ed | ||
|
|
5b073c4978 | ||
|
|
52c6e599f7 | ||
|
|
500de36308 | ||
|
|
d33fb4c9ca | ||
|
|
702be10567 | ||
|
|
8fae96dcbd | ||
|
|
403701b258 | ||
|
|
2c2b6393c0 | ||
|
|
6b7ebb2111 | ||
|
|
cce904ba6d | ||
|
|
e93e156c5f | ||
|
|
7d42cd3f07 | ||
|
|
590d47906e | ||
|
|
af75cf55b5 | ||
|
|
b376588d54 | ||
|
|
b003385ed2 | ||
|
|
742384327c | ||
|
|
bbdd0affba | ||
|
|
8f5f280597 | ||
|
|
fbbcd0ad58 | ||
|
|
209230dfd3 | ||
|
|
0d71a34ff9 | ||
|
|
4baa5ec3c6 | ||
|
|
22bcfafa76 | ||
|
|
09aa95fe2e | ||
|
|
adcb1e0d49 | ||
|
|
d3a6757d5e | ||
|
|
f49735094b | ||
|
|
5020ef10ea | ||
|
|
c11eee546d | ||
|
|
96d7d6eb94 | ||
|
|
cd58573730 | ||
|
|
159f589c0c | ||
|
|
597428b6c5 | ||
|
|
e071532d35 | ||
|
|
b771465d99 | ||
|
|
45f42dbb5e | ||
|
|
27104c05f1 | ||
|
|
82307cde6c | ||
|
|
5ca2abb8d0 | ||
|
|
e1684fc8c0 | ||
|
|
8c605dcd96 | ||
|
|
ff88e7f2f9 | ||
|
|
27e192440e | ||
|
|
a3dfdf7622 | ||
|
|
2393b81174 | ||
|
|
fac98dc970 | ||
|
|
12bdc36c92 | ||
|
|
dd1127ff77 | ||
|
|
3f8688fb73 | ||
|
|
028ef449dc | ||
|
|
41cf9cf205 | ||
|
|
c3d256a774 | ||
|
|
3f49829ecb | ||
|
|
0fe2936729 | ||
|
|
1a47e7325a | ||
|
|
ff5a3eea33 | ||
|
|
376ad001d3 | ||
|
|
b6d1b2502e | ||
|
|
338df85549 | ||
|
|
95b17c44ec | ||
|
|
4242387403 | ||
|
|
6a269bcede | ||
|
|
d2b1caf7aa | ||
|
|
b1c36dc521 | ||
|
|
194219319e | ||
|
|
b3307b5557 | ||
|
|
3da195b5f1 | ||
|
|
b05986169b | ||
|
|
1624aa2646 | ||
|
|
164681dcdd | ||
|
|
bfd4335e61 | ||
|
|
958ea6813c | ||
|
|
c2c8b3d073 | ||
|
|
642ab6465d | ||
|
|
306dc2e4d8 | ||
|
|
7cbf8a0a84 | ||
|
|
3b9be5d69b | ||
|
|
9a431f6e41 | ||
|
|
7ac9bee098 | ||
|
|
5a2c13e6c4 | ||
|
|
291b681e61 | ||
|
|
d075e9b1f9 | ||
|
|
828bcc0964 | ||
|
|
4673230092 | ||
|
|
0227c54843 | ||
|
|
33de540183 | ||
|
|
7c71b0d826 | ||
|
|
966b49fb77 | ||
|
|
db69ad4555 | ||
|
|
d3f3b23a18 | ||
|
|
d424a20602 | ||
|
|
61ecc9e9a1 | ||
|
|
e032315195 | ||
|
|
1acb00d2cb | ||
|
|
d325527aa6 | ||
|
|
533ed06eb8 | ||
|
|
a9448a8cdd | ||
|
|
2a2881ec00 | ||
|
|
c6f0b4d1bc | ||
|
|
f62d47f856 | ||
|
|
4d85dbb0ab | ||
|
|
0babe02665 | ||
|
|
2a780be1c3 | ||
|
|
2e8c9cc619 | ||
|
|
1e925591f3 | ||
|
|
44b429b459 | ||
|
|
ad0bc43eeb | ||
|
|
cc8fdc7370 | ||
|
|
392cab1b1c | ||
|
|
981d332610 | ||
|
|
28c33dcb46 | ||
|
|
086b82ab75 | ||
|
|
0631afa382 | ||
|
|
ada8da7296 | ||
|
|
7199d0f4c2 | ||
|
|
df123ed796 | ||
|
|
01666c8f3d | ||
|
|
ee4883b448 | ||
|
|
addac1062d | ||
|
|
276caefc92 | ||
|
|
68e97cceb8 | ||
|
|
5a2acda1a3 | ||
|
|
a9e664f41d | ||
|
|
9ad0ca9839 | ||
|
|
7f9344a572 | ||
|
|
8cba657773 | ||
|
|
01acd9a90e | ||
|
|
8c11713a5a | ||
|
|
a2191b57d3 | ||
|
|
ef3ff0d811 | ||
|
|
7522877051 | ||
|
|
ba96c291b4 | ||
|
|
ec3e52d60f | ||
|
|
3b00a61cba | ||
|
|
98b28ce185 | ||
|
|
0c06506fd5 | ||
|
|
e7329952f3 | ||
|
|
4b87c3532f | ||
|
|
fdd3df058b | ||
|
|
df47e839bc | ||
|
|
fca267f1f4 | ||
|
|
9adf49a413 | ||
|
|
eac36dff28 | ||
|
|
f6676b1aa9 | ||
|
|
2d4a60c77c | ||
|
|
ab709bafa6 | ||
|
|
8420d55491 | ||
|
|
65cce4a726 | ||
|
|
75c7caba86 | ||
|
|
62ad192a6b | ||
|
|
a3cb7c5817 | ||
|
|
2f26756bb0 | ||
|
|
2db871668b |
@@ -0,0 +1,3 @@
|
|||||||
|
.github
|
||||||
|
bin/
|
||||||
|
./pgweb
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
bindata.go -diff
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
name: checks
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
paths-ignore:
|
||||||
|
- '**.md'
|
||||||
|
pull_request:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- synchronize
|
||||||
|
paths-ignore:
|
||||||
|
- '**.md'
|
||||||
|
|
||||||
|
|
||||||
|
env:
|
||||||
|
GO_VERSION: 1.19
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
tests:
|
||||||
|
name: tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
pg_version: [9.6, 10, 11, 12, 13, 14, 15]
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:${{ matrix.pg_version }}
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: booktown
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
steps:
|
||||||
|
- name: Install latest Postgres client
|
||||||
|
run: |
|
||||||
|
sudo rm -f /etc/apt/sources.list.d/pgdg.list
|
||||||
|
curl --silent https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add
|
||||||
|
echo "deb http://apt.postgresql.org/pub/repos/apt/ `lsb_release -cs`-pgdg main" | sudo tee /etc/apt/sources.list.d/pgdg.list
|
||||||
|
sudo apt-get update && sudo apt-get install -y postgresql-client-15
|
||||||
|
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: actions/setup-go@v3
|
||||||
|
with:
|
||||||
|
go-version: ${{ env.GO_VERSION }}
|
||||||
|
- run: go mod download
|
||||||
|
- run: make test
|
||||||
|
env:
|
||||||
|
MallocNanoZone: 0 # https://github.com/golang/go/issues/49138
|
||||||
|
PGHOST: localhost
|
||||||
|
PGUSER: postgres
|
||||||
|
PGPASSWORD: postgres
|
||||||
|
PGDATABASE: booktown
|
||||||
|
|
||||||
|
tests-windows:
|
||||||
|
runs-on: windows-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: actions/setup-go@v3
|
||||||
|
with:
|
||||||
|
go-version: ${{ env.GO_VERSION }}
|
||||||
|
- run: go mod download
|
||||||
|
- run: make test
|
||||||
|
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/setup-go@v3
|
||||||
|
with:
|
||||||
|
go-version: ${{ env.GO_VERSION }}
|
||||||
|
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: golangci-lint
|
||||||
|
uses: golangci/golangci-lint-action@v3
|
||||||
|
with:
|
||||||
|
version: v1.50.1
|
||||||
|
|
||||||
|
fmt:
|
||||||
|
name: fmt
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: actions/setup-go@v3
|
||||||
|
with:
|
||||||
|
go-version: ${{ env.GO_VERSION }}
|
||||||
|
- run: go mod download
|
||||||
|
- run: script/check_formatting.sh
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
name: demo deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
|
env:
|
||||||
|
FLY_API_TOKEN: ${{ secrets.FLY_TOKEN }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: Deploy to Fly
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: superfly/flyctl-actions/setup-flyctl@master
|
||||||
|
- run: flyctl deploy --remote-only
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
name: docker
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
|
env:
|
||||||
|
GO_VERSION: 1.19
|
||||||
|
CGO_ENABLED: 0
|
||||||
|
IMAGE_REPOSITORY: sosedoff/pgweb
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docker-build:
|
||||||
|
name: docker images
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v2
|
||||||
|
|
||||||
|
- name: Configure docker build context
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
|
||||||
|
- name: Build docker images
|
||||||
|
uses: docker/build-push-action@v2
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: false
|
||||||
|
tags: pgweb:latest
|
||||||
|
platforms: linux/amd64,linux/arm64,linux/arm/v5,linux/arm/v7
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
name: release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
env:
|
||||||
|
GO_VERSION: 1.19
|
||||||
|
CGO_ENABLED: 0
|
||||||
|
DOCKER_REPOSITORY: sosedoff/pgweb
|
||||||
|
GHCR_REPOSITORY: sosedoff/pgweb
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docker-release:
|
||||||
|
name: Publish Docker images
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v2
|
||||||
|
|
||||||
|
- name: Configure docker build context
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
|
||||||
|
- name: Set reference tags
|
||||||
|
id: refs
|
||||||
|
run: |
|
||||||
|
echo ::set-output name=SOURCE_NAME::${GITHUB_REF#refs/*/}
|
||||||
|
echo ::set-output name=SOURCE_BRANCH::${GITHUB_REF#refs/heads/}
|
||||||
|
echo ::set-output name=SOURCE_TAG::${GITHUB_REF#refs/tags/v}
|
||||||
|
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Login to Github Container Registry
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GH_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push docker images
|
||||||
|
uses: docker/build-push-action@v2
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.DOCKER_REPOSITORY }}:${{ steps.refs.outputs.SOURCE_TAG }}
|
||||||
|
${{ env.DOCKER_REPOSITORY }}:latest
|
||||||
|
ghcr.io/${{ env.GHCR_REPOSITORY }}:${{ steps.refs.outputs.SOURCE_TAG }}
|
||||||
|
ghcr.io/${{ env.GHCR_REPOSITORY }}:latest
|
||||||
|
platforms: linux/amd64,linux/arm64,linux/arm/v5,linux/arm/v7
|
||||||
+3
-1
@@ -1,4 +1,6 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
|
.idea
|
||||||
pgweb
|
pgweb
|
||||||
bin
|
bin
|
||||||
bindata.go
|
tmp/
|
||||||
|
cover.out
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
# API
|
|
||||||
|
|
||||||
Current endpoint:
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /info
|
|
||||||
GET /tables
|
|
||||||
GET /tables/:table
|
|
||||||
GET /tables/:table/indexes
|
|
||||||
GET /query
|
|
||||||
POST /query
|
|
||||||
GET /explain
|
|
||||||
POST /explain
|
|
||||||
GET /history
|
|
||||||
```
|
|
||||||
|
|
||||||
# Query Response
|
|
||||||
|
|
||||||
Successful response:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"columns": [
|
|
||||||
"column_name1",
|
|
||||||
"column_name2",
|
|
||||||
"column_name3"
|
|
||||||
],
|
|
||||||
"rows": [
|
|
||||||
[
|
|
||||||
"column 1 value",
|
|
||||||
"column 2 value",
|
|
||||||
"column 3 value"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Error response:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Error message"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
+421
@@ -0,0 +1,421 @@
|
|||||||
|
# 0.13.1 - 2022-12-27
|
||||||
|
|
||||||
|
- Fix connect flow when `~/.pgweb/bookmarks` directory is not available, GH-631
|
||||||
|
|
||||||
|
# 0.13.0 - 2022-12-25
|
||||||
|
|
||||||
|
- Add support for .pgpass file, GH-617
|
||||||
|
- Request logging additions (request id, forwarded user), GH-618
|
||||||
|
- Establish connections using bookmark ID only, GH-619
|
||||||
|
- Display empty schemas on the sidebar, GH-621
|
||||||
|
- Configure timeout and retries when testing connection status, GH-623
|
||||||
|
- Setup basic prom metrics endpoint, GH-624
|
||||||
|
- Add default connect_timeout option to connection string, GH-626
|
||||||
|
- Add duration_ms to log entries, GH-628
|
||||||
|
- Add query execution stats to api endpoint, GH-629
|
||||||
|
|
||||||
|
# 0.12.0 - 2022-12-13
|
||||||
|
|
||||||
|
- Deprecate usage of Gox for binary builds, GH-571
|
||||||
|
- Add netcat install in dockerfile to provide a way to healthcheck, GH-572
|
||||||
|
- Install latest postgres client in docker image, GH-577
|
||||||
|
- Add support for `PGWEB_` prefix environment variables, GH-585
|
||||||
|
- Fix export URL generation, refactor export code, GH-588
|
||||||
|
- Add logrus-based request logger, GH-589
|
||||||
|
- Configure logger for connect backend, GH-591
|
||||||
|
- Set LDFLAGS for make build/release commands, GH-592
|
||||||
|
- Add internal sessions manager, GH-593
|
||||||
|
- Include index size on the index list view, GH-595
|
||||||
|
- Fix flaky backend connection test, GH-596
|
||||||
|
- Add ability to view and copy views/materialized views definitions, GH-594
|
||||||
|
- Enable dev assets mode with PGWEB_ASSETS_DEVMODE env var, GH-597
|
||||||
|
- Make query input box resizable, GH-599
|
||||||
|
- Deprecate Heroku demo deployments and switch to Fly, GH-600
|
||||||
|
- Handle returning values in update/delete queries, GH-601
|
||||||
|
- Fix panic with invalid time marshaling, GH-602
|
||||||
|
- Configure logging level and format, GH-605
|
||||||
|
- Use go embed to load queries from static files, GH-607
|
||||||
|
- Switch go build target to 1.19, GH-603
|
||||||
|
- Add support for user functions, GH-608
|
||||||
|
- Implement global query timeout option, GH-609
|
||||||
|
- Switch windows tests from Appveyor to Github Actions, GH-611
|
||||||
|
- Fix activity endpoint panic when server version is not detected, GH-612
|
||||||
|
|
||||||
|
# 0.11.12 - 2022-07-05
|
||||||
|
|
||||||
|
- Update base docker image (alpine), update deps, GH-558
|
||||||
|
- Refactor docker images building, include ARM, GH-568
|
||||||
|
|
||||||
|
# 0.11.11 - 2022-03-29
|
||||||
|
|
||||||
|
- Auto-detect the query from the query source based on user selection, GH-547
|
||||||
|
- Added binary codec base58 as well as improving the help for --binary-codec flag, GH-548
|
||||||
|
- Change binary codec back to none, GH-555
|
||||||
|
|
||||||
|
## 0.11.10 - 2022-01-20
|
||||||
|
|
||||||
|
- Removes alert on column copy value, GH-536
|
||||||
|
- Migrate test suite to Github Action, GH-540
|
||||||
|
- Serialize binary bytea cols into hex/base64, GH-537
|
||||||
|
- Include build time into version string, GH-541
|
||||||
|
- Explain analyze dropdown button, GH-532
|
||||||
|
- Switch to go 1.17, GH-543
|
||||||
|
- Use HTTP 302 status code for successful backend redirect, GH-544
|
||||||
|
- Add connect backend tests, GH-546
|
||||||
|
|
||||||
|
## 0.11.9 - 2021-11-08
|
||||||
|
|
||||||
|
- Releases are built on Go 1.17
|
||||||
|
- Build time correction, GH-521
|
||||||
|
- Fix broken assets URL path prefix, GH-525
|
||||||
|
- Update docker build image to alpine:3.14, GH-522
|
||||||
|
- Upgrade gin dependency to v1.7.4, GH-527
|
||||||
|
- Add FreeBSD startup script, GH-520
|
||||||
|
|
||||||
|
## 0.11.8 - 2021-07-07
|
||||||
|
|
||||||
|
- Releases are built with Go 1.16
|
||||||
|
- Add ARM64 v7 build target, GH-497
|
||||||
|
- Switch to Go modules for dependency management, GH-509
|
||||||
|
- Switch to Go embed for static assets management, GH-510
|
||||||
|
- Add Darwin/ARM64 build target (Apple Silicon), GH-513
|
||||||
|
|
||||||
|
## 0.11.7 - 2020-10-18
|
||||||
|
|
||||||
|
- Releases are built with Go 1.15
|
||||||
|
- Show results row context menu on custom query results, GH-457
|
||||||
|
- Do not terminate if local authentication failed on start, GH-463
|
||||||
|
- Do not show other databases if session is locked, GH-470
|
||||||
|
- Strip debug information from binary to reduce size, GH-489
|
||||||
|
- Disable autocomplete on database search field, GH-492
|
||||||
|
- Improve windows connection error matching during start, GH-493
|
||||||
|
|
||||||
|
## 0.11.6 - 2020-02-19
|
||||||
|
|
||||||
|
- Add CLI options for SSL key, cert and root certs, GH-452
|
||||||
|
- Remove double click action on cell, GH-455
|
||||||
|
|
||||||
|
## 0.11.5 - 2019-12-16
|
||||||
|
|
||||||
|
- Add basic SQL keyword autocompletion, GH-443
|
||||||
|
- SSH Private Key handling update (encrypted keys are supported now), GH-445
|
||||||
|
- Include Go version into `pgweb --version` output, GH-447
|
||||||
|
- Fix long table name bug in the sidebar, GH-448
|
||||||
|
- Add SQL objects (table,views,etc) autocompletion, GH-449
|
||||||
|
- Include Go version into info API endpoint, GH-450
|
||||||
|
|
||||||
|
## 0.11.4 - 2019-10-05
|
||||||
|
|
||||||
|
- Fix SQL export filename, GH-438
|
||||||
|
- Update Docker image to alpine:3.10, GH-439
|
||||||
|
- Drop unsupported pg_dump options from connection string, GH-441
|
||||||
|
- Misc code cleanup and formatting, GH-442
|
||||||
|
|
||||||
|
## 0.11.3 - 2019-07-24
|
||||||
|
|
||||||
|
- Misc: add script to update homebrew formula version, GH-423
|
||||||
|
- Destructive keyword restriction in read-only mode, GH-421
|
||||||
|
- Make database object searchable in sidebar, GH-434
|
||||||
|
- Update lib/pg to 1.1.1, GH-435
|
||||||
|
|
||||||
|
## 0.11.2 - 2019-02-15
|
||||||
|
|
||||||
|
- Fix table row estimation query for camelcase schemas, GH-414
|
||||||
|
|
||||||
|
## 0.11.1 - 2019-01-28
|
||||||
|
|
||||||
|
- Typo fixes
|
||||||
|
- Add Base64 javascript encoder/decoder to replace deprecated window.atob call, GH-405
|
||||||
|
- Fix startup error when DATABASE_URL is set, GH-406
|
||||||
|
- Fix user auto detection when USER env var is not set, GH-408
|
||||||
|
- Switch bindata dependency to use maintained fork: github.com/go-bindata/go-bindata, GH-409
|
||||||
|
|
||||||
|
## 0.11.0 - 2018-12-24
|
||||||
|
|
||||||
|
- Tweak sidebar database object counters styles, GH-400
|
||||||
|
- Do not exit with error if local server is not running, GH-399
|
||||||
|
- Fix SSH host verification check, GH-398
|
||||||
|
- Scope activity list to current database only, GH-397
|
||||||
|
- Show current release version and check for updates, GH-396
|
||||||
|
- Force switch back to default connection settings view, GH-395
|
||||||
|
- Fix row count estimation bug, GH-394
|
||||||
|
- Print out failed query SQL and args with --debug flag, GH-393
|
||||||
|
|
||||||
|
## 0.10.0 - 2018-11-28
|
||||||
|
|
||||||
|
- Fixes relation not found errors when dealing with table names that have uppercase characters, GH-356
|
||||||
|
- Dockerfile updates, GH-357
|
||||||
|
- Check if pg_dump is available before running database export, GH-358
|
||||||
|
- Improvements to CockroachDB integration, GH-365
|
||||||
|
- Add EstimatedTableRowsCount to avoid count in large tables, GH-366
|
||||||
|
- Automatically set table filter option to 'equals' if its not set, GH-370
|
||||||
|
- Dependencies update and switch to dep, GH-375
|
||||||
|
- Add column context menu item to get numeric stats, GH-377
|
||||||
|
- Fix issues with connection string builder, GH-378
|
||||||
|
- Include rows count to numeric stats view on table column, GH-379
|
||||||
|
- Make localhost to be a default db host, GH-380
|
||||||
|
- Clear out connection settings/bookmark on login screen when running in session/connect mode
|
||||||
|
- Add table row context menu with actions, GH-381
|
||||||
|
- Allow settings url prefix with URL_PREFIX env var, GH-387
|
||||||
|
- Fix JSON marshal panic when dealing with NaN values, GH-388
|
||||||
|
- Fix startup behavior when user did not provide a database name, GH-389
|
||||||
|
|
||||||
|
## 0.9.12 - 2018-04-23
|
||||||
|
|
||||||
|
- Add link to view database connection string format on login page
|
||||||
|
- Include constraint name under "constraints" tab, GH-343
|
||||||
|
- Misc CI and config changes
|
||||||
|
|
||||||
|
## 0.9.11 - 2017-12-07
|
||||||
|
|
||||||
|
- Fix ssl mode for the connection url in the bookmarks, GH-320
|
||||||
|
- Add support for CORS, GH-321
|
||||||
|
- Fix custom query results counter for empty queries, GH-322
|
||||||
|
- Reorganize the table context menu, GH-323
|
||||||
|
- Disable database connection string text field autocomplete, GH-327
|
||||||
|
- Add db prefix to the table export files, GH-329
|
||||||
|
- Add database view context menu with export actions, GH-330
|
||||||
|
|
||||||
|
## 0.9.10 - 2017-11-03
|
||||||
|
|
||||||
|
- Make idle connection timeout configurable, [GH-282]
|
||||||
|
- Fix panics when sshinfo is not set on bookmarks, [GH-296]
|
||||||
|
- Dot now allow using startup bookmark in multi-session mode, [GH-300]
|
||||||
|
- Add ability to copy table name from the sidebar, [GH-301]
|
||||||
|
|
||||||
|
## 0.9.9 - 2017-09-28
|
||||||
|
|
||||||
|
- Automatically format JSON data exports, GH-255
|
||||||
|
- Update Docker image to alpine:3.6, GH-256
|
||||||
|
- Print out PostgreSQL server version on start in a single-session mode, GH-264
|
||||||
|
- Record last query timestamp for the client connection, GH-265
|
||||||
|
- Add context menu for table headers in browse mode (copy name, see unique values), GH-268
|
||||||
|
- Add ability to export current database dump, GH-270
|
||||||
|
- Automatically open pgweb in browser on start if its already running, GH-272
|
||||||
|
- Connect to the database with credentials provided by a third-party backend, GH-266
|
||||||
|
- Automatically close idle sessions (no activity in 1 hour), GH-275
|
||||||
|
- Allow connecting via SSH with a custom private key and other fixes, GH-277
|
||||||
|
- Add options to disable SSH connections, GH-279
|
||||||
|
|
||||||
|
## 0.9.8 - 2017-08-04
|
||||||
|
|
||||||
|
- Fixed error checking in the API, GH-234
|
||||||
|
- Fixed activity tab to support PG 9.x versions, GH-237
|
||||||
|
- Remember sort column and order for pagination, GH-240
|
||||||
|
- Use `sslmode=disable` for bookmarks without sslmode option, GH-244
|
||||||
|
- Javascript fixes for IE9-11, GH-245
|
||||||
|
- Require confirmation for the disconnect, GH-246
|
||||||
|
- Clean the results table on manual disconnect
|
||||||
|
|
||||||
|
## 0.9.7 - 2017-04-04
|
||||||
|
|
||||||
|
- Fixed issue with locked session and empty db url, GH-206
|
||||||
|
- Fixed path rewrite on DB change, GH-212
|
||||||
|
- Upgraded dependencies, GH-217
|
||||||
|
- Added ability to specify bookmarks path, GH-218
|
||||||
|
- Added counter for the number of rows from a custom SQL query, GH-224
|
||||||
|
- Added new behavior for removing table rows view on custom SQL query page, GH-225
|
||||||
|
|
||||||
|
## 0.9.6 - 2016-11-18
|
||||||
|
|
||||||
|
- Fixed bug in query base64-encoding, GH-186
|
||||||
|
- Fixed rows pagination visibility bug, GH-190
|
||||||
|
- Fixed issue with query order escaping, GH-191
|
||||||
|
- Fixed invalid query selection for explain command, GH-198
|
||||||
|
- Fixed issue with empty sidebar, now it shows empty state, GH-202
|
||||||
|
- Added new flag --readonly to enable read only transaction mode, GH-193
|
||||||
|
- Added ability to kill any running query, GH-194
|
||||||
|
- Added session database connection locking, GH-195
|
||||||
|
- Added ability to switch between databases, GH-196
|
||||||
|
- Added feature to keep last selected tab when switching between tables, GH-197
|
||||||
|
- Added new flag --bookmark (-b) to specify server connection from bookmark, GH-201
|
||||||
|
|
||||||
|
## 0.9.5 - 2016-10-01
|
||||||
|
|
||||||
|
- Only view schema with USAGE privileges, GH-167
|
||||||
|
- Fixed broken export to CSV/JSON/XML if hashmark in URL, GH-175
|
||||||
|
- Added example service configuration for systemd, GH-177
|
||||||
|
- Allow setting auth user and pass using variables
|
||||||
|
|
||||||
|
## 0.9.4 - 2016-07-29
|
||||||
|
|
||||||
|
- Fixes CSV/JSON/XML export buttons when pgweb is running with url prefix, GH-170
|
||||||
|
|
||||||
|
## 0.9.3 - 2016-06-30
|
||||||
|
|
||||||
|
- Uses Go 1.6 for development, GH-155
|
||||||
|
- Fixes timestamp formatting in CSV export, GH-163
|
||||||
|
- Included PostgreSQL 9.6 for integration testing
|
||||||
|
- Switches docker image to Alpine to reduce image size
|
||||||
|
- Adds support for ARMv5
|
||||||
|
|
||||||
|
## 0.9.2 - 2016-03-01
|
||||||
|
|
||||||
|
- Fixes bug with unsafe base64 encoded sql queries
|
||||||
|
- Fixes issue with session id not being included in multi-session mode
|
||||||
|
- Fixes visual issue with long table names in sidebar
|
||||||
|
- Fixes visual issue with a scrollbar in table information widget
|
||||||
|
- Fixes issue with database connection form being reset by clicking on 'cancel' button
|
||||||
|
- Adds ability to close connection
|
||||||
|
- Adds display message for number of affected rows for update/delete queries, GH-133
|
||||||
|
- Adds web server url prefix as a CLI option, GH-135
|
||||||
|
|
||||||
|
## 0.9.1 - 2016-01-25
|
||||||
|
|
||||||
|
- Fixes bug with tables context menu
|
||||||
|
- Fixes JS bug when query returns no rows
|
||||||
|
- Fixes bug with switching between different connection modes
|
||||||
|
- Adds AJAX timeout to 5s
|
||||||
|
- Adds sidebar reload action on any CREATE/DROP action
|
||||||
|
|
||||||
|
## 0.9.0 - 2016-01-19
|
||||||
|
|
||||||
|
- Add support for multiple schemas. GH-112
|
||||||
|
- Add support for native ssh tunnes. GH-114
|
||||||
|
- Add materialized views to list of schema objects
|
||||||
|
- Adds a few design tweaks and cleanups
|
||||||
|
- Fixes bug with nil result set when fetching rows
|
||||||
|
|
||||||
|
## 0.8.0 - 2016-01-11
|
||||||
|
|
||||||
|
- Fixes bug with bigint conversions in javascript. Now bigints are encoded as strings. GH-109
|
||||||
|
- Adds pagination and simple column filtering to table rows browser. GH-110
|
||||||
|
- Adds ability to use pgweb with multiple database sessions. GH-111
|
||||||
|
- Adds a few design tweaks and cleanups
|
||||||
|
|
||||||
|
## 0.7.0 - 2016-01-05
|
||||||
|
|
||||||
|
- Adds sequences to the sidebar panel - GH-100
|
||||||
|
- Adds table constrains view - GH-104
|
||||||
|
- Adds ability to export table and query rows as JSON/XML - GH-107
|
||||||
|
- Updates to UI theme and SQL editor
|
||||||
|
|
||||||
|
## 0.6.3 - 2015-08-16
|
||||||
|
|
||||||
|
- Adds PostgreSQL password escaping in web ui, GH-96
|
||||||
|
- Adds base64 query encoding for CSV export, GH-95
|
||||||
|
- Adds automatic saving of last executed query to localStorage
|
||||||
|
- Adds request middleware to log incoming form params in debug mode
|
||||||
|
|
||||||
|
## 0.6.2 - 2015-07-15
|
||||||
|
|
||||||
|
- Adds ability to specify connection strings prefixed by `postgresql://`, [GH-92]
|
||||||
|
- Updates configuration for Heroku, [GH-89], [GH-90]
|
||||||
|
- Updates postgresql library dependency to latest, [GH-91]
|
||||||
|
- Fixes password field to not display plaintext passwords, [GH-87]
|
||||||
|
|
||||||
|
## 0.6.1 - 2015-06-18
|
||||||
|
|
||||||
|
- This release is repackage-release targeted to fix binary downloads
|
||||||
|
|
||||||
|
## 0.6.0 - 2015-05-31
|
||||||
|
|
||||||
|
- Adds ability to execute only selected SQL query in run command view, [GH-85]
|
||||||
|
- Adds ability to delete/truncate table via context meny on sidebar view
|
||||||
|
- Adds ability to export table contents to CSV via context menu on sidebar view
|
||||||
|
- Changes sidebar color scheme to a lighter and better looking one
|
||||||
|
|
||||||
|
## 0.5.3 - 2015-05-06
|
||||||
|
|
||||||
|
- Changes default server port from 8080 to 8081 to avoil conflict with RethinkDB
|
||||||
|
- Changes styles for table rows and connection settings window
|
||||||
|
- Adds highlighting styles for columns with sort order
|
||||||
|
- Adds git sha into program version output
|
||||||
|
- Add new endpoint /api/info to get build details
|
||||||
|
|
||||||
|
## 0.5.2 - 2015-04-13
|
||||||
|
|
||||||
|
- Adds a new endpoint /activity that retuns active queries
|
||||||
|
- Adds tab to view active queries
|
||||||
|
- Adds column sorting when browsing table contents
|
||||||
|
- Fixes SQL query view when switching to table structure view
|
||||||
|
|
||||||
|
## 0.5.1 - 2015-02-23
|
||||||
|
|
||||||
|
- Upgrades Gin framework dependency to 0.5.0
|
||||||
|
- Fixes server crash if another pgweb server is running
|
||||||
|
|
||||||
|
## 0.5.0 - 2015-01-13
|
||||||
|
|
||||||
|
- Adds Go 1.4 support
|
||||||
|
- Adds connection string printing in debug mode
|
||||||
|
- Adds initial bookmarks support
|
||||||
|
- Adds /api prefix for all API calls
|
||||||
|
- Adds makefile usage task
|
||||||
|
- Adds windows CI to verify build process
|
||||||
|
- Adds example sql database to codebase
|
||||||
|
- Adds timestamped filenames when exporting results to CSV [GH-75]
|
||||||
|
- Adds connection checking on each request to prevent api panics
|
||||||
|
- Adds timestamps to query history records
|
||||||
|
- Adds current database name to the sidebar
|
||||||
|
- Adds button to refresh tables list to the sidebar
|
||||||
|
- Updates all application dependencies
|
||||||
|
- Changes /api/info endpoint to /api/connection
|
||||||
|
- Fixes issues with connection string/options parsing
|
||||||
|
- Fixes capitalized column names in table view
|
||||||
|
- Fixes connection string validation in /api/connect endpoint
|
||||||
|
|
||||||
|
## 0.4.1 - 2014-12-01
|
||||||
|
|
||||||
|
- Adds pgweb version on start [GH-65]
|
||||||
|
- Adds user detection from OS environment
|
||||||
|
- Adds simple memory profiles with --debug option
|
||||||
|
- Adds the session user and search path in connection info [GH-67]
|
||||||
|
- Adds table list reloading after CREATE/DROP TABLE queries [GH-69]
|
||||||
|
- Adds font awesome icons for the sidebar
|
||||||
|
- Removes query recording for internal queries [GH-67]
|
||||||
|
- Fixes default sslmode. Its not longer set to "disable"
|
||||||
|
- Fixes cells cropping on table indexes view
|
||||||
|
- Fixes connection URL generation using web interface
|
||||||
|
- Fixes SQL statements for table row count [GH-67]
|
||||||
|
- Fixes /tables JSON response if database does not have any tables
|
||||||
|
|
||||||
|
## 0.4.0 - 2014-11-11
|
||||||
|
|
||||||
|
- Adds query escaping when exporting results to CSV [GH-38]
|
||||||
|
- Adds keyboard shortcut (ctrl+e, command+e on mac) for query explain action
|
||||||
|
- Adds HTTP basic authentication with --auth-user and --auth-pass flags
|
||||||
|
- Adds -skip-open/-s flag to disable automatic browser launch
|
||||||
|
- Adds --bind option to specify server listen hostname/ip
|
||||||
|
- Adds ssl mode parameters to url if ssl flag is set and not defined in the url
|
||||||
|
- Adds dependency management with Godep
|
||||||
|
- Adds Docker support
|
||||||
|
- Adds Heroku support
|
||||||
|
- Adds ability to connect to databases with no tables
|
||||||
|
- Adds precompiled assets into repository to simplify development
|
||||||
|
- Adds a connection details view
|
||||||
|
- Adds a new interface to specify connection settings or make a new connection
|
||||||
|
- Adds page favicon
|
||||||
|
- Adds ability to present cell data as text area by double clicking on it
|
||||||
|
- Fixes styles for query explain results
|
||||||
|
- Fixes sidebar navigation scrolling styles [GH-12]
|
||||||
|
- Fixes sidebar table name styles to support long names
|
||||||
|
|
||||||
|
## 0.3.1 - 2014-10-28
|
||||||
|
|
||||||
|
- Adds proper exit code when printing version via -v/--version flag
|
||||||
|
- Adds --version and --debug long flag names
|
||||||
|
- Adds double quotes for table name when fetching table contents
|
||||||
|
- Adds support for DATABASE_URL environment variable if no --url is set
|
||||||
|
- Adds proper usage of jQuery .prop method
|
||||||
|
- Adds --pass flag to specify connection password
|
||||||
|
- Fixes --ssl flag usage, previous value was hardcoded
|
||||||
|
|
||||||
|
## 0.3.0 - 2014-10-26
|
||||||
|
|
||||||
|
- Renamed `make deps` to `make setup` and fix issues with bootstrapping
|
||||||
|
- Removed hardcoded url for CSV export, it now detects application host:port
|
||||||
|
- Improved query history view table styles
|
||||||
|
- Moved table information view to the sidebar
|
||||||
|
- Added --listen flag to specify web server port, still defaults to 8080
|
||||||
|
|
||||||
|
## 0.2.0 - 2014-10-23
|
||||||
|
|
||||||
|
- Design tweaks
|
||||||
|
- Automatically opens browser on OSX systems
|
||||||
|
- Adds query explain functionality
|
||||||
|
- Adds export to CSV
|
||||||
|
|
||||||
|
## 0.1.0 - 2014-10-14
|
||||||
|
|
||||||
|
- Initial release
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
- Fork repository
|
||||||
|
- Create a new git branch
|
||||||
|
- Make changes
|
||||||
|
- Run tests: `make test`
|
||||||
|
- Run tests agains all supported PostreSQL versions: `make test-all` (optional)
|
||||||
|
- If you change frontend code (js/css) make sure to rebuild assets: `make assets`
|
||||||
|
- Open a new pull request
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# Builder Stage
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
FROM golang:1.19-bullseye AS build
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
ADD . /build
|
||||||
|
|
||||||
|
RUN git config --global --add safe.directory /build
|
||||||
|
RUN go mod download
|
||||||
|
RUN make build
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# Release Stage
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
FROM debian:bullseye-slim
|
||||||
|
|
||||||
|
RUN \
|
||||||
|
apt-get update && \
|
||||||
|
apt-get install -y ca-certificates openssl netcat curl gnupg lsb-release && \
|
||||||
|
update-ca-certificates
|
||||||
|
|
||||||
|
RUN \
|
||||||
|
curl --silent https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add && \
|
||||||
|
echo "deb http://apt.postgresql.org/pub/repos/apt/ `lsb_release -cs`-pgdg main" | tee /etc/apt/sources.list.d/pgdg.list && \
|
||||||
|
apt-get update && apt-get install -y postgresql-client
|
||||||
|
|
||||||
|
RUN \
|
||||||
|
apt-get clean autoclean && \
|
||||||
|
apt-get autoremove --yes && \
|
||||||
|
rm -rf /var/lib/{apt,dpkg,cache,log}/
|
||||||
|
|
||||||
|
COPY --from=build /build/pgweb /usr/bin/pgweb
|
||||||
|
|
||||||
|
EXPOSE 8081
|
||||||
|
|
||||||
|
CMD ["/usr/bin/pgweb", "--bind=0.0.0.0", "--listen=8081"]
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
Copyright (c) 2014 Dan Sosedoff <dan.sosedoff@gmail.com>
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2014-2023 Dan Sosedoff <dan.sosedoff@gmail.com>
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
this software and associated documentation files (the "Software"), to deal in
|
this software and associated documentation files (the "Software"), to deal in
|
||||||
@@ -15,4 +17,4 @@ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
|||||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
|||||||
@@ -1,20 +1,73 @@
|
|||||||
build:
|
PKG = github.com/sosedoff/pgweb
|
||||||
rm -f bindata.go
|
GIT_COMMIT ?= $(shell git rev-parse HEAD)
|
||||||
go-bindata -ignore=\\.gitignore -ignore=\\.DS_Store -ignore=\\.gitkeep static/...
|
BUILD_TIME ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ" | tr -d '\n')
|
||||||
gox -osarch="darwin/amd64 linux/amd64 linux/386 windows/amd64 windows/386" -output="./bin/pgweb_{{.OS}}_{{.Arch}}"
|
GO_VERSION ?= $(shell go version | awk {'print $$3'})
|
||||||
|
|
||||||
|
DOCKER_RELEASE_TAG = "sosedoff/pgweb:$(shell git describe --abbrev=0 --tags | sed 's/v//')"
|
||||||
|
DOCKER_LATEST_TAG = "sosedoff/pgweb:latest"
|
||||||
|
|
||||||
|
LDFLAGS = -s -w
|
||||||
|
LDFLAGS += -X $(PKG)/pkg/command.GitCommit=$(GIT_COMMIT)
|
||||||
|
LDFLAGS += -X $(PKG)/pkg/command.BuildTime=$(BUILD_TIME)
|
||||||
|
LDFLAGS += -X $(PKG)/pkg/command.GoVersion=$(GO_VERSION)
|
||||||
|
|
||||||
|
usage:
|
||||||
|
@echo ""
|
||||||
|
@echo "Task : Description"
|
||||||
|
@echo "----------------- : -------------------"
|
||||||
|
@echo "make dev : Generate development build"
|
||||||
|
@echo "make build : Generate production build for current OS"
|
||||||
|
@echo "make release : Generate binaries for all supported OSes"
|
||||||
|
@echo "make test : Execute test suite"
|
||||||
|
@echo "make test-all : Execute test suite on multiple PG versions"
|
||||||
|
@echo "make lint : Execute code linter"
|
||||||
|
@echo "make clean : Remove all build files and reset assets"
|
||||||
|
@echo "make docker : Build docker image"
|
||||||
|
@echo "make docker-release : Build and tag docker image"
|
||||||
|
@echo "make docker-push : Push docker images to registry"
|
||||||
|
@echo ""
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test -v -race -cover ./pkg/...
|
||||||
|
|
||||||
|
test-all:
|
||||||
|
@./script/test_all.sh
|
||||||
|
@./script/test_cockroach.sh
|
||||||
|
|
||||||
|
lint:
|
||||||
|
golangci-lint run
|
||||||
|
|
||||||
dev:
|
dev:
|
||||||
rm -f bindata.go
|
|
||||||
go-bindata -debug -ignore=\\.gitignore -ignore=\\.DS_Store -ignore=\\.gitkeep static/...
|
|
||||||
go build
|
go build
|
||||||
@echo "You can now execute ./pgweb"
|
@echo "You can now execute ./pgweb"
|
||||||
|
|
||||||
deps:
|
build:
|
||||||
go get
|
go build -ldflags '${LDFLAGS}'
|
||||||
go get github.com/mitchellh/gox
|
@echo "You can now execute ./pgweb"
|
||||||
go get github.com/jteeuwen/go-bindata/...
|
|
||||||
|
install:
|
||||||
|
go install -ldflags '${LDFLAGS}'
|
||||||
|
@echo "You can now execute pgweb"
|
||||||
|
|
||||||
|
release: clean
|
||||||
|
@echo "Building binaries..."
|
||||||
|
@LDFLAGS='${LDFLAGS}' ./script/build_all.sh
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -f ./pgweb
|
@echo "Removing all artifacts"
|
||||||
rm -f ./bin/*
|
@rm -rf ./pgweb ./bin/*
|
||||||
rm -r bindata.go
|
|
||||||
|
docker:
|
||||||
|
docker build --no-cache -t pgweb .
|
||||||
|
|
||||||
|
docker-run:
|
||||||
|
docker run --rm -p 8081:8081 -it pgweb
|
||||||
|
|
||||||
|
docker-release:
|
||||||
|
docker build --no-cache -t $(DOCKER_RELEASE_TAG) .
|
||||||
|
docker tag $(DOCKER_RELEASE_TAG) $(DOCKER_LATEST_TAG)
|
||||||
|
docker images $(DOCKER_RELEASE_TAG)
|
||||||
|
|
||||||
|
docker-push:
|
||||||
|
docker push $(DOCKER_RELEASE_TAG)
|
||||||
|
docker push $(DOCKER_LATEST_TAG)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
web: pgweb --url=$DATABASE_URL --listen=$PORT --bind=0.0.0.0 --auth-user=$AUTH_USER --auth-pass=$AUTH_PASS
|
||||||
@@ -1,114 +1,109 @@
|
|||||||
# pgweb
|
# pgweb
|
||||||
|
|
||||||
Web-based PostgreSQL database browser written in Go.
|
Simple web-based and cross platform PostgreSQL database explorer.
|
||||||
|
|
||||||
|
[](https://github.com/sosedoff/pgweb/releases)
|
||||||
|
[](https://github.com/sosedoff/pgweb/actions?query=branch%3Amaster)
|
||||||
|
[](https://goreportcard.com/report/github.com/sosedoff/pgweb)
|
||||||
|
[](https://godoc.org/github.com/sosedoff/pgweb)
|
||||||
|
[](https://hub.docker.com/r/sosedoff/pgweb/)
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
This is a web-based browser for PostgreSQL database server. Its written in Go
|
Pgweb is a web-based database explorer for PostgreSQL, written in Go, and works
|
||||||
and works on Mac OSX, Linux and Windows machines. Main idea behind using Go for the backend
|
on Mac, Linux and Windows machines. Distributed as a simple binary with zero dependencies.
|
||||||
is to utilize language's ability for cross-compile source code for multiple platforms.
|
Very easy to use and packs just the right amount of features.
|
||||||
This project is an attempt to create a very simple and portable application to work with
|
|
||||||
PostgreSQL databases.
|
|
||||||
|
|
||||||
<img src="screenshots/browse.png" width="345px" />
|
[See application screenshots](SCREENS.md)
|
||||||
<img src="screenshots/query.png" width="345px" />
|
|
||||||
|
|
||||||
Features:
|
## Features
|
||||||
|
|
||||||
- Connect to local or remote server
|
- Cross-platform: Mac/Linux/Windows (64bit).
|
||||||
- Browse tables and table rows
|
- Simple installation (distributed as a single binary).
|
||||||
- Get table details: structure, size, indices, row count
|
- Zero dependencies.
|
||||||
- Execute SQL query and run analyze on it
|
- Works with PostgreSQL 9.1+.
|
||||||
- Export query results to CSV
|
- Supports native SSH tunnels.
|
||||||
- View query history
|
- Multiple database sessions.
|
||||||
|
- Execute and analyze custom SQL queries.
|
||||||
|
- Table and query data export to CSV/JSON/XML.
|
||||||
|
- Query history.
|
||||||
|
- Server bookmarks.
|
||||||
|
|
||||||
|
Visit [WIKI](https://github.com/sosedoff/pgweb/wiki) for more details.
|
||||||
|
|
||||||
|
## Demo
|
||||||
|
|
||||||
|
Visit https://pgweb-demo.fly.dev/ to see Pgweb in action.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
Please visit [Github Releases](https://github.com/sosedoff/pgweb/releases) to download a
|
- [Precompiled binaries](https://github.com/sosedoff/pgweb/releases) for supported operating systems are available.
|
||||||
precompiled binary for your operating system.
|
- [More installation options](https://github.com/sosedoff/pgweb/wiki/Installation)
|
||||||
|
|
||||||
Currently supported:
|
|
||||||
|
|
||||||
- Mac OSX 64bit
|
|
||||||
- Linux 32/64bit
|
|
||||||
- Windows 32/64bit
|
|
||||||
|
|
||||||
Supported PostgreSQL versions:
|
|
||||||
|
|
||||||
- 9.1
|
|
||||||
- 9.2
|
|
||||||
- 9.3
|
|
||||||
|
|
||||||
Older versions of PostgreSQL might also work but this project is not tested on
|
|
||||||
8.x branches.
|
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
Start server:
|
Start server:
|
||||||
|
|
||||||
|
```
|
||||||
|
pgweb
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also provide connection flags:
|
||||||
|
|
||||||
```
|
```
|
||||||
pgweb --host localhost --user myuser --db mydb
|
pgweb --host localhost --user myuser --db mydb
|
||||||
```
|
```
|
||||||
|
|
||||||
You can also specify a connection URI instead of individual flags:
|
Connection URL scheme is also supported:
|
||||||
|
|
||||||
```
|
```
|
||||||
pgweb --url postgresql://user:password@host:port/database
|
pgweb --url postgres://user:password@host:port/database?sslmode=[mode]
|
||||||
|
pgweb --url "postgres:///database?host=/absolute/path/to/unix/socket/dir"
|
||||||
```
|
```
|
||||||
|
|
||||||
It works great with [Heroku Postgres](https://postgres.heroku.com) if you need
|
### Multiple database sessions
|
||||||
to troubleshoot production database or simply run a few queries.
|
|
||||||
|
|
||||||
Full CLI options:
|
To enable multiple database sessions in pgweb, start the server with:
|
||||||
|
|
||||||
```
|
```
|
||||||
Usage:
|
pgweb --sessions
|
||||||
pgweb [OPTIONS]
|
|
||||||
|
|
||||||
Application Options:
|
|
||||||
-v Print version
|
|
||||||
-d Enable debugging mode (false)
|
|
||||||
--url= Database connection string
|
|
||||||
--host= Server hostname or IP (localhost)
|
|
||||||
--port= Server port (5432)
|
|
||||||
--user= Database user (postgres)
|
|
||||||
--db= Database name (postgres)
|
|
||||||
--ssl= SSL option (disable)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Compile from source
|
Or set environment variable:
|
||||||
|
|
||||||
Go 1.3+ is required. You can install Go with `homebrew`:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
brew install go
|
PGWEB_SESSIONS=1 pgweb
|
||||||
```
|
```
|
||||||
|
|
||||||
To compile source code run the following command:
|
## Testing
|
||||||
|
|
||||||
|
Before running tests, make sure you have PostgreSQL server running on `localhost:5432`
|
||||||
|
interface. Also, you must have `postgres` user that could create new databases
|
||||||
|
in your local environment. Pgweb server should not be running at the same time.
|
||||||
|
|
||||||
|
Execute test suite:
|
||||||
|
|
||||||
```
|
```
|
||||||
make deps
|
make test
|
||||||
make dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
This will produce `pgweb` binary in the current directory.
|
If you're using Docker locally, you might also run pgweb test suite against
|
||||||
|
all supported PostgreSQL version with a single command:
|
||||||
There's also a task to compile binaries for other operating systems:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
make build
|
make test-all
|
||||||
```
|
```
|
||||||
|
|
||||||
Under the hood it uses [gox](https://github.com/mitchellh/gox). Compiled binaries
|
## Contribute
|
||||||
will be stored into `./bin` directory.
|
|
||||||
|
|
||||||
## Contributors
|
- Fork this repository
|
||||||
|
- Create a new feature branch for a new functionality or bugfix
|
||||||
- Dan Sosedoff - https://twitter.com/sosedoff
|
- Commit your changes
|
||||||
- Masha Safina - https://twitter.com/mashasafina
|
- Execute test suite
|
||||||
|
- Push your code and open a new pull request
|
||||||
|
- Use [issues](https://github.com/sosedoff/pgweb/issues) for any questions
|
||||||
|
- Check [wiki](https://github.com/sosedoff/pgweb/wiki) for extra documentation
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
The MIT License (MIT)
|
The MIT License (MIT). See [LICENSE](LICENSE) file for more details.
|
||||||
|
|
||||||
Copyright (c) 2014 Dan Sosedoff, <dan.sosedoff@gmail.com>
|
|
||||||
|
|||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
# Screenshots
|
||||||
|
|
||||||
|
### Connect
|
||||||
|
<img src="screenshots/connect.png" />
|
||||||
|
|
||||||
|
### Browse table rows
|
||||||
|
<img src="screenshots/browse.png" />
|
||||||
|
|
||||||
|
### Write SQL queries
|
||||||
|
<img src="screenshots/query.png" />
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
## TODO
|
|
||||||
|
|
||||||
- [ ] Change server port to something better
|
|
||||||
- [ ] Add ability to switch between databases
|
|
||||||
- [x] More detailed usage / help section
|
|
||||||
- [x] Build for linux and windows
|
|
||||||
- [ ] Add view to see table details (not only structure)
|
|
||||||
- [ ] Make results table rows sortable
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Error struct {
|
|
||||||
Message string `json:"error"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func assetContentType(name string) string {
|
|
||||||
if strings.Contains(name, ".css") {
|
|
||||||
return "text/css"
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(name, ".js") {
|
|
||||||
return "application/javascript"
|
|
||||||
}
|
|
||||||
|
|
||||||
return "text/plain"
|
|
||||||
}
|
|
||||||
|
|
||||||
func API_Home(c *gin.Context) {
|
|
||||||
data, err := Asset("static/index.html")
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.String(400, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Data(200, "text/html; charset=utf-8", data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func API_GetDatabases(c *gin.Context) {
|
|
||||||
names, err := dbClient.Databases()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(400, NewError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(200, names)
|
|
||||||
}
|
|
||||||
|
|
||||||
func API_RunQuery(c *gin.Context) {
|
|
||||||
query := strings.TrimSpace(c.Request.FormValue("query"))
|
|
||||||
|
|
||||||
if query == "" {
|
|
||||||
c.JSON(400, errors.New("Query parameter is missing"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
API_HandleQuery(query, c)
|
|
||||||
}
|
|
||||||
|
|
||||||
func API_ExplainQuery(c *gin.Context) {
|
|
||||||
query := strings.TrimSpace(c.Request.FormValue("query"))
|
|
||||||
|
|
||||||
if query == "" {
|
|
||||||
c.JSON(400, errors.New("Query parameter is missing"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
API_HandleQuery(fmt.Sprintf("EXPLAIN ANALYZE %s", query), c)
|
|
||||||
}
|
|
||||||
|
|
||||||
func API_GetTables(c *gin.Context) {
|
|
||||||
names, err := dbClient.Tables()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(400, NewError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(200, names)
|
|
||||||
}
|
|
||||||
|
|
||||||
func API_GetTable(c *gin.Context) {
|
|
||||||
res, err := dbClient.Table(c.Params.ByName("table"))
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(400, NewError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(200, res)
|
|
||||||
}
|
|
||||||
|
|
||||||
func API_GetTableInfo(c *gin.Context) {
|
|
||||||
res, err := dbClient.TableInfo(c.Params.ByName("table"))
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(400, NewError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(200, res)
|
|
||||||
}
|
|
||||||
|
|
||||||
func API_History(c *gin.Context) {
|
|
||||||
c.JSON(200, dbClient.history)
|
|
||||||
}
|
|
||||||
|
|
||||||
func API_Info(c *gin.Context) {
|
|
||||||
res, err := dbClient.Info()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(400, NewError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(200, res.Format()[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
func API_TableIndexes(c *gin.Context) {
|
|
||||||
res, err := dbClient.TableIndexes(c.Params.ByName("table"))
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(400, NewError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(200, res)
|
|
||||||
}
|
|
||||||
|
|
||||||
func API_HandleQuery(query string, c *gin.Context) {
|
|
||||||
result, err := dbClient.Query(query)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(400, NewError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
q := c.Request.URL.Query()
|
|
||||||
|
|
||||||
if len(q["format"]) > 0 {
|
|
||||||
if q["format"][0] == "csv" {
|
|
||||||
c.Data(200, "text/csv", result.CSV())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(200, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func API_ServeAsset(c *gin.Context) {
|
|
||||||
file := fmt.Sprintf(
|
|
||||||
"static/%s/%s",
|
|
||||||
c.Params.ByName("type"),
|
|
||||||
c.Params.ByName("name"),
|
|
||||||
)
|
|
||||||
|
|
||||||
data, err := Asset(file)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
c.String(400, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(data) == 0 {
|
|
||||||
c.String(404, "Asset is empty")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Data(200, assetContentType(file), data)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "pgweb",
|
||||||
|
"description": "A web-based frontend to your Postgres database.",
|
||||||
|
"website": "https://github.com/sosedoff/pgweb",
|
||||||
|
"repository": "https://github.com/sosedoff/pgweb",
|
||||||
|
"logo": "https://raw.githubusercontent.com/sosedoff/pgweb/master/static/img/icon.png",
|
||||||
|
"success_url": "/",
|
||||||
|
"keywords": [
|
||||||
|
"postgres",
|
||||||
|
"database",
|
||||||
|
"golang",
|
||||||
|
"frontend"
|
||||||
|
],
|
||||||
|
"addons": [
|
||||||
|
"heroku-postgresql"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"AUTH_USER": {
|
||||||
|
"description": "A username to log into pgweb.",
|
||||||
|
"value": "admin"
|
||||||
|
},
|
||||||
|
"AUTH_PASS": {
|
||||||
|
"description": "The login password. Please change this.",
|
||||||
|
"generator": "secret"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/csv"
|
|
||||||
"fmt"
|
|
||||||
"github.com/jmoiron/sqlx"
|
|
||||||
"reflect"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
PG_INFO = "SELECT version(), user, current_database(), inet_client_addr(), inet_client_port(), inet_server_addr(), inet_server_port()"
|
|
||||||
PG_DATABASES = "SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname ASC;"
|
|
||||||
PG_TABLES = "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' ORDER BY table_schema,table_name;"
|
|
||||||
PG_TABLE_SCHEMA = "SELECT column_name, data_type, is_nullable, character_maximum_length, character_set_catalog, column_default FROM information_schema.columns where table_name = '%s';"
|
|
||||||
PG_TABLE_INDEXES = "SELECT indexname, indexdef FROM pg_indexes WHERE tablename = '%s';"
|
|
||||||
PG_TABLE_INFO = "SELECT pg_size_pretty(pg_table_size('%s')) AS data_size, pg_size_pretty(pg_indexes_size('%s')) AS index_size, pg_size_pretty(pg_total_relation_size('%s')) AS total_size, (SELECT COUNT(*) FROM %s) AS rows_count"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Client struct {
|
|
||||||
db *sqlx.DB
|
|
||||||
history []string
|
|
||||||
}
|
|
||||||
|
|
||||||
type Result struct {
|
|
||||||
Columns []string `json:"columns"`
|
|
||||||
Rows [][]interface{} `json:"rows"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewError(err error) Error {
|
|
||||||
return Error{err.Error()}
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewClient() (*Client, error) {
|
|
||||||
db, err := sqlx.Open("postgres", getConnectionString())
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Client{db: db}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (client *Client) Test() error {
|
|
||||||
return client.db.Ping()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (client *Client) recordQuery(query string) {
|
|
||||||
client.history = append(client.history, query)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (client *Client) Info() (*Result, error) {
|
|
||||||
return client.Query(PG_INFO)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (client *Client) Databases() ([]string, error) {
|
|
||||||
res, err := client.Query(PG_DATABASES)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var tables []string
|
|
||||||
|
|
||||||
for _, row := range res.Rows {
|
|
||||||
tables = append(tables, row[0].(string))
|
|
||||||
}
|
|
||||||
|
|
||||||
return tables, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (client *Client) Tables() ([]string, error) {
|
|
||||||
res, err := client.Query(PG_TABLES)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var tables []string
|
|
||||||
|
|
||||||
for _, row := range res.Rows {
|
|
||||||
tables = append(tables, row[0].(string))
|
|
||||||
}
|
|
||||||
|
|
||||||
return tables, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (client *Client) Table(table string) (*Result, error) {
|
|
||||||
return client.Query(fmt.Sprintf(PG_TABLE_SCHEMA, table))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (client *Client) TableInfo(table string) (*Result, error) {
|
|
||||||
return client.Query(fmt.Sprintf(PG_TABLE_INFO, table, table, table, table))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (client *Client) TableIndexes(table string) (*Result, error) {
|
|
||||||
res, err := client.Query(fmt.Sprintf(PG_TABLE_INDEXES, table))
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return res, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (client *Client) Query(query string) (*Result, error) {
|
|
||||||
rows, err := client.db.Queryx(query)
|
|
||||||
|
|
||||||
client.recordQuery(query)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
cols, err := rows.Columns()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
result := Result{
|
|
||||||
Columns: cols,
|
|
||||||
}
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
obj, err := rows.SliceScan()
|
|
||||||
|
|
||||||
for i, item := range obj {
|
|
||||||
if item == nil {
|
|
||||||
obj[i] = nil
|
|
||||||
} else {
|
|
||||||
t := reflect.TypeOf(item).Kind().String()
|
|
||||||
|
|
||||||
if t == "slice" {
|
|
||||||
obj[i] = string(item.([]byte))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err == nil {
|
|
||||||
result.Rows = append(result.Rows, obj)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (res *Result) Format() []map[string]interface{} {
|
|
||||||
var items []map[string]interface{}
|
|
||||||
|
|
||||||
for _, row := range res.Rows {
|
|
||||||
item := make(map[string]interface{})
|
|
||||||
|
|
||||||
for i, c := range res.Columns {
|
|
||||||
item[c] = row[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
items = append(items, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
func (res *Result) CSV() []byte {
|
|
||||||
buff := &bytes.Buffer{}
|
|
||||||
writer := csv.NewWriter(buff)
|
|
||||||
|
|
||||||
writer.Write(res.Columns)
|
|
||||||
|
|
||||||
for _, row := range res.Rows {
|
|
||||||
record := make([]string, len(res.Columns))
|
|
||||||
|
|
||||||
for i, item := range row {
|
|
||||||
if item != nil {
|
|
||||||
record[i] = fmt.Sprintf("%v", item)
|
|
||||||
} else {
|
|
||||||
record[i] = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err := writer.Write(record)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println(err)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
writer.Flush()
|
|
||||||
return buff.Bytes()
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# connect-backend-go
|
||||||
|
|
||||||
|
Example Golang backend for Pgweb Connect feature
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Run the backend:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
Configure pgweb:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pgweb --sessions --connect-backend=http://localhost:4567 --connect-token=test
|
||||||
|
```
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BackendRequest struct {
|
||||||
|
Resource string `json:"resource"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
Headers map[string]string `json:"headers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BackendResponse struct {
|
||||||
|
DatabaseURL string `json:"database_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
resources := map[string]string{
|
||||||
|
"id1": "postgres://localhost:5432/db1?sslmode=disable",
|
||||||
|
"id2": "postgres://localhost:5432/db2?sslmode=disable",
|
||||||
|
"id3": "postgres://localhost:5432/db3?sslmode=disable",
|
||||||
|
}
|
||||||
|
|
||||||
|
http.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
backendReq := BackendRequest{}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(req.Body).Decode(&backendReq); err != nil {
|
||||||
|
rw.WriteHeader(400)
|
||||||
|
fmt.Fprintf(rw, "error while parsing request: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res, ok := resources[backendReq.Resource]
|
||||||
|
if !ok {
|
||||||
|
rw.WriteHeader(404)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := BackendResponse{
|
||||||
|
DatabaseURL: res,
|
||||||
|
}
|
||||||
|
|
||||||
|
json.NewEncoder(rw).Encode(resp)
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := http.ListenAndServe(":4567", nil); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
source "https://rubygems.org"
|
||||||
|
|
||||||
|
gem "sinatra"
|
||||||
|
gem "json"
|
||||||
|
gem "puma"
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
GEM
|
||||||
|
remote: https://rubygems.org/
|
||||||
|
specs:
|
||||||
|
json (2.6.2)
|
||||||
|
mustermann (3.0.0)
|
||||||
|
ruby2_keywords (~> 0.0.1)
|
||||||
|
nio4r (2.5.8)
|
||||||
|
puma (6.0.0)
|
||||||
|
nio4r (~> 2.0)
|
||||||
|
rack (2.2.6.2)
|
||||||
|
rack-protection (3.0.4)
|
||||||
|
rack
|
||||||
|
ruby2_keywords (0.0.5)
|
||||||
|
sinatra (3.0.4)
|
||||||
|
mustermann (~> 3.0)
|
||||||
|
rack (~> 2.2, >= 2.2.4)
|
||||||
|
rack-protection (= 3.0.4)
|
||||||
|
tilt (~> 2.0)
|
||||||
|
tilt (2.0.11)
|
||||||
|
|
||||||
|
PLATFORMS
|
||||||
|
arm64-darwin-21
|
||||||
|
x86_64-linux
|
||||||
|
|
||||||
|
DEPENDENCIES
|
||||||
|
json
|
||||||
|
puma
|
||||||
|
sinatra
|
||||||
|
|
||||||
|
BUNDLED WITH
|
||||||
|
2.3.9
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# connect-backend-ruby
|
||||||
|
|
||||||
|
Example Ruby backend for Pgweb Connect feature
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Install and run the backend:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bundle install
|
||||||
|
ruby main.rb
|
||||||
|
```
|
||||||
|
|
||||||
|
Configure pgweb:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pgweb --sessions --connect-backend=http://localhost:4567 --connect-token=test
|
||||||
|
```
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
require "bundler/setup"
|
||||||
|
require "./main"
|
||||||
|
|
||||||
|
run Sinatra::Application
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
require "sinatra"
|
||||||
|
|
||||||
|
# Authentication token
|
||||||
|
$token = "test"
|
||||||
|
|
||||||
|
# List of all availble resources
|
||||||
|
$resources = {
|
||||||
|
"id1" => "postgres://localhost:5432/db1?sslmode=disable",
|
||||||
|
"id2" => "postgres://localhost:5432/db2?sslmode=disable",
|
||||||
|
"id3" => "postgres://localhost:5432/db3?sslmode=disable"
|
||||||
|
}
|
||||||
|
|
||||||
|
helpers do
|
||||||
|
def error(code, message)
|
||||||
|
halt(code, JSON.dump(error: message))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
content_type :json
|
||||||
|
end
|
||||||
|
|
||||||
|
post "/" do
|
||||||
|
req = JSON.load(request.body) || {}
|
||||||
|
|
||||||
|
unless req["resource"]
|
||||||
|
halt 404, "Resource ID required"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check the resource
|
||||||
|
resource = $resources[req["resource"]]
|
||||||
|
if !resource
|
||||||
|
halt 404, "Invalid resource ID"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Return connection credentials
|
||||||
|
JSON.dump(
|
||||||
|
database_url: resource
|
||||||
|
)
|
||||||
|
end
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
#
|
||||||
|
# $FreeBSD: $
|
||||||
|
#
|
||||||
|
# PROVIDE: pgweb
|
||||||
|
# REQUIRE: NETWORKING
|
||||||
|
# KEYWORD:
|
||||||
|
#
|
||||||
|
# Add the following lines to /etc/rc.conf to enable pgweb:
|
||||||
|
# pgweb_enable="YES"
|
||||||
|
#
|
||||||
|
# pgweb_enable (bool): Set to YES to enable pgweb
|
||||||
|
# Default: NO
|
||||||
|
# pgweb_bind (str): HTTP server host
|
||||||
|
# Default: localhost
|
||||||
|
# pgweb_listen (str): HTTP server listen port
|
||||||
|
# Default: 8081
|
||||||
|
# pgweb_user (str): pgweb daemon user
|
||||||
|
# Default: www
|
||||||
|
# pgweb_group (str): pgweb daemon group
|
||||||
|
# Default: www
|
||||||
|
|
||||||
|
. /etc/rc.subr
|
||||||
|
|
||||||
|
name="pgweb"
|
||||||
|
rcvar="pgweb_enable"
|
||||||
|
load_rc_config $name
|
||||||
|
|
||||||
|
: ${pgweb_user:="www"}
|
||||||
|
: ${pgweb_group:="www"}
|
||||||
|
: ${pgweb_enable:="NO"}
|
||||||
|
: ${pgweb_bind:="localhost"}
|
||||||
|
: ${pgweb_flags=""}
|
||||||
|
: ${pgweb_facility:="daemon"}
|
||||||
|
: ${pgweb_priority:="debug"}
|
||||||
|
: ${pgweb_listen:="8081"}
|
||||||
|
|
||||||
|
procname="/usr/local/bin/${name}"
|
||||||
|
pidfile="/var/run/${name}.pid"
|
||||||
|
start_precmd="${name}_precmd"
|
||||||
|
command=/usr/sbin/daemon
|
||||||
|
command_args="-S -l ${pgweb_facility} -s ${pgweb_priority} -T ${name} -t ${name} -p ${pidfile} \
|
||||||
|
${procname} --bind=${pgweb_bind} --listen=${pgweb_listen} ${pgweb_flags}"
|
||||||
|
|
||||||
|
pgweb_precmd()
|
||||||
|
{
|
||||||
|
install -o ${pgweb_user} /dev/null ${pidfile}
|
||||||
|
}
|
||||||
|
|
||||||
|
run_rc_command "$1"
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=pgweb - Cross-platform client for PostgreSQL databases
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/bin/pgweb --bind=0.0.0.0 --listen=8081
|
||||||
|
Restart=on-abort
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
### BEGIN INIT INFO
|
||||||
|
# Provides: pgweb
|
||||||
|
# Required-Start: $remote_fs $syslog
|
||||||
|
# Required-Stop: $remote_fs $syslog
|
||||||
|
# Default-Start: 2 3 4 5
|
||||||
|
# Default-Stop: 0 1 6
|
||||||
|
# Short-Description: Start daemon at boot time
|
||||||
|
# Description: Enable service provided by daemon.
|
||||||
|
### END INIT INFO
|
||||||
|
|
||||||
|
# Installation instructions (originally written for Debian)
|
||||||
|
#
|
||||||
|
# Save this script into /etc/init.d/pgweb file, make it executable,
|
||||||
|
# and install it into the boot sequence:
|
||||||
|
#
|
||||||
|
# chmod 755 /etc/init.d/pgweb
|
||||||
|
# update-rc.d pgweb defaults
|
||||||
|
#
|
||||||
|
# This script assumes that pgweb binary is located at /home/pgweb/, and that
|
||||||
|
# there's a bookmark 'server' in /home/pgweb/.pgweb/bookmarks/.
|
||||||
|
#
|
||||||
|
|
||||||
|
NAME="pgweb"
|
||||||
|
PIDFILE="/var/run/$NAME.pid"
|
||||||
|
|
||||||
|
USER="pgweb" # Linux system user
|
||||||
|
SU="su $USER -s /bin/bash"
|
||||||
|
|
||||||
|
TIMEOUT=5 # Time in seconds to wait postgresql to show up
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
start)
|
||||||
|
if [ -f $PIDFILE ]; then
|
||||||
|
echo "Already running... cat $PIDFILE"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Wait postgresql to show up
|
||||||
|
while ! test -f /var/run/postgresql/*main.pid
|
||||||
|
do
|
||||||
|
sleep 1
|
||||||
|
TIMEOUT=`expr $TIMEOUT - 1`
|
||||||
|
if test $TIMEOUT -eq 0; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Ready to start pgweb
|
||||||
|
PID=`$SU -c '/home/pgweb/pgweb -s -b server >/dev/null & echo $!'` # Note! Logs are lost.
|
||||||
|
if [ -z $PID ]; then
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo $PID > $PIDFILE
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
stop)
|
||||||
|
PID=`cat $PIDFILE`
|
||||||
|
kill $PID && rm $PIDFILE
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Usage: /etc/init.d/$NAME {start|stop}"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
description "PgWeb as a Service for Ubuntu 14.04 With Upstart"
|
||||||
|
|
||||||
|
start on runlevel [2345]
|
||||||
|
stop on runlevel [!2345]
|
||||||
|
|
||||||
|
respawn
|
||||||
|
setuid youruser
|
||||||
|
setgid www-data
|
||||||
|
|
||||||
|
exec /usr/bin/pgweb --bind=0.0.0.0 --listen=8081
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
host = "localhost"
|
||||||
|
port = 5432
|
||||||
|
user = "postgres"
|
||||||
|
database = "mydatabase"
|
||||||
|
ssl = "disable"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
host = "localhost"
|
||||||
|
port = 5432
|
||||||
|
user = "postgres"
|
||||||
|
database = "mydatabase"
|
||||||
|
ssl = "disabled"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
url = "postgres://username:password@host:port/database?sslmode=disable"
|
||||||
+1880
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
invalid encoding
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- pgweb: host="localhost"
|
||||||
|
select 'foo'
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- pgweb: host="localhost"
|
||||||
|
-- some comment
|
||||||
|
-- pgweb: user="foo"
|
||||||
|
|
||||||
|
select 'foo'
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- pgweb: host="localhost" mode="foo"
|
||||||
|
select 'foo'
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
select 'foo'
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
localhost:5432:dbname:username:password
|
||||||
|
127.0.0.1:5432:*:*:password2
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
DROP DATABASE IF EXISTS "roach";
|
||||||
|
CREATE DATABASE "roach";
|
||||||
|
USE "roach";
|
||||||
|
|
||||||
|
CREATE TABLE product_information (
|
||||||
|
product_id INT PRIMARY KEY NOT NULL,
|
||||||
|
product_name STRING(50) UNIQUE NOT NULL,
|
||||||
|
product_description STRING(2000),
|
||||||
|
category_id STRING(1) NOT NULL CHECK (category_id IN ('A','B','C')),
|
||||||
|
weight_class INT,
|
||||||
|
warranty_period INT CONSTRAINT valid_warranty CHECK (warranty_period BETWEEN 0 AND 24),
|
||||||
|
supplier_id INT,
|
||||||
|
product_status STRING(20),
|
||||||
|
list_price DECIMAL(8,2),
|
||||||
|
min_price DECIMAL(8,2),
|
||||||
|
catalog_url STRING(50) UNIQUE,
|
||||||
|
date_added DATE DEFAULT CURRENT_DATE(),
|
||||||
|
misc JSONB,
|
||||||
|
CONSTRAINT price_check CHECK (list_price >= min_price),
|
||||||
|
INDEX date_added_idx (date_added),
|
||||||
|
INDEX supp_id_prod_status_idx (supplier_id, product_status),
|
||||||
|
INVERTED INDEX details (misc)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO product_information VALUES
|
||||||
|
(1, 'Product A', 'Text', 'A', NULL, 1),
|
||||||
|
(2, 'Product B', 'Text', 'B', NULL, 2),
|
||||||
|
(3, 'Product C', 'Text', 'C', NULL, 3);
|
||||||
|
|
||||||
|
CREATE TABLE customers (
|
||||||
|
id INT PRIMARY KEY,
|
||||||
|
name STRING
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE orders (
|
||||||
|
id INT PRIMARY KEY,
|
||||||
|
customer_id INT REFERENCES customers(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO customers VALUES (1, 'Lauren');
|
||||||
|
INSERT INTO orders VALUES (1,1);
|
||||||
|
DELETE FROM customers WHERE id = 1;
|
||||||
|
SELECT * FROM orders;
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
x-base: &base
|
||||||
|
environment: &env
|
||||||
|
POSTGRES_DB: pgweb
|
||||||
|
POSTGRES_PASSWORD: pgweb
|
||||||
|
POSTGRES_USER: pgweb
|
||||||
|
healthcheck:
|
||||||
|
test: pg_isready -U pgweb -h 127.0.0.1
|
||||||
|
interval: 5s
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres15:
|
||||||
|
<<: *base
|
||||||
|
image: postgres:15
|
||||||
|
ports:
|
||||||
|
- 5433:5432
|
||||||
|
postgres14:
|
||||||
|
<<: *base
|
||||||
|
image: postgres:14
|
||||||
|
ports:
|
||||||
|
- 5434:5432
|
||||||
|
postgres13:
|
||||||
|
<<: *base
|
||||||
|
image: postgres:13
|
||||||
|
ports:
|
||||||
|
- 5435:5432
|
||||||
|
postgres12:
|
||||||
|
<<: *base
|
||||||
|
image: postgres:12
|
||||||
|
ports:
|
||||||
|
- 5436:5432
|
||||||
|
postgres11:
|
||||||
|
<<: *base
|
||||||
|
image: postgres:11
|
||||||
|
ports:
|
||||||
|
- 5437:5432
|
||||||
|
postgres10:
|
||||||
|
<<: *base
|
||||||
|
image: postgres:10
|
||||||
|
ports:
|
||||||
|
- 5438:5432
|
||||||
|
postgres9.6:
|
||||||
|
<<: *base
|
||||||
|
image: postgres:9.6
|
||||||
|
ports:
|
||||||
|
- 5439:5432
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
container_name: pgweb-postgres
|
||||||
|
image: postgres:14
|
||||||
|
ports:
|
||||||
|
- 5433:5432
|
||||||
|
volumes:
|
||||||
|
- data:/var/lib/postgresql/data
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: pgweb
|
||||||
|
POSTGRES_PASSWORD: pgweb
|
||||||
|
POSTGRES_USER: pgweb
|
||||||
|
healthcheck:
|
||||||
|
test: pg_isready -U pgweb -h 127.0.0.1
|
||||||
|
interval: 5s
|
||||||
|
networks:
|
||||||
|
- pgweb
|
||||||
|
|
||||||
|
pgweb:
|
||||||
|
container_name: pgweb
|
||||||
|
image: sosedoff/pgweb:latest
|
||||||
|
environment:
|
||||||
|
PGWEB_DATABASE_URL: postgres://pgweb:pgweb@pgweb-postgres:5432/pgweb?sslmode=disable
|
||||||
|
ports:
|
||||||
|
- 8081:8081
|
||||||
|
networks:
|
||||||
|
- pgweb
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "nc", "-vz", "127.0.0.1", "8081"]
|
||||||
|
interval: 5s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
data:
|
||||||
|
name: pgweb_postgres
|
||||||
|
|
||||||
|
networks:
|
||||||
|
pgweb:
|
||||||
|
name: pgweb
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
app = "pgweb-demo"
|
||||||
|
kill_signal = "SIGINT"
|
||||||
|
kill_timeout = 5
|
||||||
|
|
||||||
|
[processes]
|
||||||
|
web = "pgweb --sessions --bind=0.0.0.0 --metrics"
|
||||||
|
|
||||||
|
[[services]]
|
||||||
|
http_checks = []
|
||||||
|
internal_port = 8081
|
||||||
|
processes = ["web"]
|
||||||
|
protocol = "tcp"
|
||||||
|
script_checks = []
|
||||||
|
|
||||||
|
[services.concurrency]
|
||||||
|
hard_limit = 25
|
||||||
|
soft_limit = 20
|
||||||
|
type = "connections"
|
||||||
|
|
||||||
|
[[services.ports]]
|
||||||
|
force_https = true
|
||||||
|
handlers = ["http"]
|
||||||
|
port = 80
|
||||||
|
|
||||||
|
[[services.ports]]
|
||||||
|
handlers = ["tls", "http"]
|
||||||
|
port = 443
|
||||||
|
|
||||||
|
[[services.tcp_checks]]
|
||||||
|
grace_period = "1s"
|
||||||
|
interval = "15s"
|
||||||
|
restart_limit = 0
|
||||||
|
timeout = "2s"
|
||||||
|
|
||||||
|
[metrics]
|
||||||
|
port = 8081
|
||||||
|
path = "/metrics"
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
module github.com/sosedoff/pgweb
|
||||||
|
|
||||||
|
go 1.19
|
||||||
|
|
||||||
|
// +heroku goVersion go1.19
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/BurntSushi/toml v1.1.0
|
||||||
|
github.com/ScaleFT/sshkeys v0.0.0-20200327173127-6142f742bca5
|
||||||
|
github.com/gin-gonic/gin v1.7.7
|
||||||
|
github.com/jackc/pgpassfile v1.0.0
|
||||||
|
github.com/jessevdk/go-flags v1.5.0
|
||||||
|
github.com/jmoiron/sqlx v1.3.5
|
||||||
|
github.com/lib/pq v1.10.5
|
||||||
|
github.com/mitchellh/go-homedir v1.1.0
|
||||||
|
github.com/mr-tron/base58 v1.2.0
|
||||||
|
github.com/prometheus/client_golang v1.14.0
|
||||||
|
github.com/sirupsen/logrus v1.9.0
|
||||||
|
github.com/stretchr/testify v1.8.0
|
||||||
|
github.com/tuvistavie/securerandom v0.0.0-20140719024926-15512123a948
|
||||||
|
golang.org/x/crypto v0.0.0-20220511200225-c6db032c6c88
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/dchest/bcrypt_pbkdf v0.0.0-20150205184540-83f37f9c154a // indirect
|
||||||
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.0 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.0 // indirect
|
||||||
|
github.com/go-playground/validator/v10 v10.9.0 // indirect
|
||||||
|
github.com/golang/protobuf v1.5.2 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/leodido/go-urn v1.2.1 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.14 // indirect
|
||||||
|
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/prometheus/client_model v0.3.0 // indirect
|
||||||
|
github.com/prometheus/common v0.39.0 // indirect
|
||||||
|
github.com/prometheus/procfs v0.8.0 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.2.6 // indirect
|
||||||
|
golang.org/x/sys v0.3.0 // indirect
|
||||||
|
golang.org/x/text v0.5.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.28.1 // indirect
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
github.com/BurntSushi/toml v1.1.0 h1:ksErzDEI1khOiGPgpwuI7x2ebx/uXQNw7xJpn9Eq1+I=
|
||||||
|
github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
|
||||||
|
github.com/ScaleFT/sshkeys v0.0.0-20200327173127-6142f742bca5 h1:VauE2GcJNZFun2Och6tIT2zJZK1v6jxALQDA9BIji/E=
|
||||||
|
github.com/ScaleFT/sshkeys v0.0.0-20200327173127-6142f742bca5/go.mod h1:gxOHeajFfvGQh/fxlC8oOKBe23xnnJTif00IFFbiT+o=
|
||||||
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
|
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||||
|
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dchest/bcrypt_pbkdf v0.0.0-20150205184540-83f37f9c154a h1:saTgr5tMLFnmy/yg3qDTft4rE5DY2uJ/cCxCe3q0XTU=
|
||||||
|
github.com/dchest/bcrypt_pbkdf v0.0.0-20150205184540-83f37f9c154a/go.mod h1:Bw9BbhOJVNR+t0jCqx2GC6zv0TGBsShs56Y3gfSCvl0=
|
||||||
|
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||||
|
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||||
|
github.com/gin-gonic/gin v1.7.7 h1:3DoBmSbJbZAWqXJC3SLjAPfutPJJRN1U5pALB7EeTTs=
|
||||||
|
github.com/gin-gonic/gin v1.7.7/go.mod h1:axIBovoeJpVj8S3BwE0uPMTeReE4+AfFtqpqaZ1qq1U=
|
||||||
|
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
|
||||||
|
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
|
||||||
|
github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU=
|
||||||
|
github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
|
||||||
|
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
|
||||||
|
github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho=
|
||||||
|
github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
|
||||||
|
github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
|
||||||
|
github.com/go-playground/validator/v10 v10.9.0 h1:NgTtmN58D0m8+UuxtYmGztBJB7VnPgjj221I1QHci2A=
|
||||||
|
github.com/go-playground/validator/v10 v10.9.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos=
|
||||||
|
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
|
||||||
|
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||||
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||||
|
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
|
||||||
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
|
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
|
||||||
|
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||||
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jessevdk/go-flags v1.5.0 h1:1jKYvbxEjfUl0fmqTCOfonvskHHXMjBySTLW4y9LFvc=
|
||||||
|
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
|
||||||
|
github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=
|
||||||
|
github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
|
||||||
|
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||||
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
|
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||||
|
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
|
||||||
|
github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
|
||||||
|
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
|
||||||
|
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||||
|
github.com/lib/pq v1.10.5 h1:J+gdV2cUmX7ZqL2B0lFcW0m+egaHC2V3lpO8nWxyYiQ=
|
||||||
|
github.com/lib/pq v1.10.5/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
|
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||||
|
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
|
||||||
|
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||||
|
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
|
||||||
|
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
|
||||||
|
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||||
|
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
|
||||||
|
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
|
||||||
|
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/prometheus/client_golang v1.14.0 h1:nJdhIvne2eSX/XRAFV9PcvFFRbrjbcTUj0VP62TMhnw=
|
||||||
|
github.com/prometheus/client_golang v1.14.0/go.mod h1:8vpkKitgIVNcqrRBWh1C4TIUQgYNtG/XQE4E/Zae36Y=
|
||||||
|
github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4=
|
||||||
|
github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w=
|
||||||
|
github.com/prometheus/common v0.39.0 h1:oOyhkDq05hPZKItWVBkJ6g6AtGxi+fy7F4JvUV8uhsI=
|
||||||
|
github.com/prometheus/common v0.39.0/go.mod h1:6XBZ7lYdLCbkAVhwRsWTZn+IN5AB9F/NXd5w0BbEX0Y=
|
||||||
|
github.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5mo=
|
||||||
|
github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4=
|
||||||
|
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||||
|
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||||
|
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||||
|
github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
|
||||||
|
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
|
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||||
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/tuvistavie/securerandom v0.0.0-20140719024926-15512123a948 h1:yL0l/u242MzDP6D0B5vGC+wxm5WRY+alQZy+dJk3bFI=
|
||||||
|
github.com/tuvistavie/securerandom v0.0.0-20140719024926-15512123a948/go.mod h1:a06d/M1pxWi51qiSrfGMHaEydtuXT06nha8N2aNQuXk=
|
||||||
|
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
|
||||||
|
github.com/ugorji/go v1.2.6/go.mod h1:anCg0y61KIhDlPZmnH+so+RQbysYVyDko0IMgJv0Nn0=
|
||||||
|
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
|
||||||
|
github.com/ugorji/go/codec v1.2.6 h1:7kbGefxLoDBuYXOms4yD7223OpNMMPNPZxXk5TvFcyQ=
|
||||||
|
github.com/ugorji/go/codec v1.2.6/go.mod h1:V6TCNZ4PHqoHGFZuSG1W8nrCzzdgA2DozYxWFFpvxTw=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.0.0-20220511200225-c6db032c6c88 h1:Tgea0cVUD0ivh5ADBX4WwuI12DUd2to3nCYe2eayMIw=
|
||||||
|
golang.org/x/crypto v0.0.0-20220511200225-c6db032c6c88/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200219091948-cb0a6d8edb6c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ=
|
||||||
|
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.5.0 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM=
|
||||||
|
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
|
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||||
|
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
|
||||||
|
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -1,134 +1,9 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"github.com/sosedoff/pgweb/pkg/cli"
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/jessevdk/go-flags"
|
|
||||||
_ "github.com/lib/pq"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"os/signal"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const VERSION = "0.2.0"
|
|
||||||
|
|
||||||
var options struct {
|
|
||||||
Version bool `short:"v" description:"Print version"`
|
|
||||||
Debug bool `short:"d" description:"Enable debugging mode" default:"false"`
|
|
||||||
Url string `long:"url" description:"Database connection string"`
|
|
||||||
Host string `long:"host" description:"Server hostname or IP" default:"localhost"`
|
|
||||||
Port int `long:"port" description:"Server port" default:"5432"`
|
|
||||||
User string `long:"user" description:"Database user" default:"postgres"`
|
|
||||||
DbName string `long:"db" description:"Database name" default:"postgres"`
|
|
||||||
Ssl string `long:"ssl" description:"SSL option" default:"disable"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var dbClient *Client
|
|
||||||
|
|
||||||
func exitWithMessage(message string) {
|
|
||||||
fmt.Println("Error:", message)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getConnectionString() string {
|
|
||||||
if options.Url != "" {
|
|
||||||
return options.Url
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf(
|
|
||||||
"host=%s port=%d user=%s dbname=%s sslmode=disable",
|
|
||||||
options.Host, options.Port,
|
|
||||||
options.User, options.DbName,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func initClient() {
|
|
||||||
client, err := NewClient()
|
|
||||||
if err != nil {
|
|
||||||
exitWithMessage(err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("Connecting to server...")
|
|
||||||
err = client.Test()
|
|
||||||
if err != nil {
|
|
||||||
exitWithMessage(err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("Checking tables...")
|
|
||||||
tables, err := client.Tables()
|
|
||||||
if err != nil {
|
|
||||||
exitWithMessage(err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(tables) == 0 {
|
|
||||||
exitWithMessage("Database does not have any tables")
|
|
||||||
}
|
|
||||||
|
|
||||||
dbClient = client
|
|
||||||
}
|
|
||||||
|
|
||||||
func initOptions() {
|
|
||||||
_, err := flags.ParseArgs(&options, os.Args)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if options.Version {
|
|
||||||
fmt.Printf("pgweb v%s\n", VERSION)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func startServer() {
|
|
||||||
router := gin.Default()
|
|
||||||
|
|
||||||
router.GET("/", API_Home)
|
|
||||||
router.GET("/databases", API_GetDatabases)
|
|
||||||
router.GET("/info", API_Info)
|
|
||||||
router.GET("/tables", API_GetTables)
|
|
||||||
router.GET("/tables/:table", API_GetTable)
|
|
||||||
router.GET("/tables/:table/info", API_GetTableInfo)
|
|
||||||
router.GET("/tables/:table/indexes", API_TableIndexes)
|
|
||||||
router.GET("/query", API_RunQuery)
|
|
||||||
router.POST("/query", API_RunQuery)
|
|
||||||
router.GET("/explain", API_ExplainQuery)
|
|
||||||
router.POST("/explain", API_ExplainQuery)
|
|
||||||
router.GET("/history", API_History)
|
|
||||||
router.GET("/static/:type/:name", API_ServeAsset)
|
|
||||||
|
|
||||||
fmt.Println("Starting server...")
|
|
||||||
go router.Run(":8080")
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleSignals() {
|
|
||||||
c := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(c, os.Interrupt, os.Kill)
|
|
||||||
<-c
|
|
||||||
}
|
|
||||||
|
|
||||||
func openPage() {
|
|
||||||
fmt.Println("To view database open http://localhost:8080 in browser")
|
|
||||||
|
|
||||||
_, err := exec.Command("which", "open").Output()
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
exec.Command("open", "http://localhost:8080").Output()
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
initOptions()
|
cli.Run()
|
||||||
initClient()
|
|
||||||
|
|
||||||
defer dbClient.db.Close()
|
|
||||||
|
|
||||||
if !options.Debug {
|
|
||||||
gin.SetMode("release")
|
|
||||||
}
|
|
||||||
|
|
||||||
startServer()
|
|
||||||
openPage()
|
|
||||||
handleSignals()
|
|
||||||
}
|
}
|
||||||
|
|||||||
+688
@@ -0,0 +1,688 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
neturl "net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/tuvistavie/securerandom"
|
||||||
|
|
||||||
|
"github.com/sosedoff/pgweb/pkg/bookmarks"
|
||||||
|
"github.com/sosedoff/pgweb/pkg/client"
|
||||||
|
"github.com/sosedoff/pgweb/pkg/command"
|
||||||
|
"github.com/sosedoff/pgweb/pkg/connection"
|
||||||
|
"github.com/sosedoff/pgweb/pkg/metrics"
|
||||||
|
"github.com/sosedoff/pgweb/pkg/queries"
|
||||||
|
"github.com/sosedoff/pgweb/pkg/shared"
|
||||||
|
"github.com/sosedoff/pgweb/static"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// DbClient represents the active database connection in a single-session mode
|
||||||
|
DbClient *client.Client
|
||||||
|
|
||||||
|
// DbSessions represents the mapping for client connections
|
||||||
|
DbSessions *SessionManager
|
||||||
|
|
||||||
|
// QueryStore reads the SQL queries stores in the home directory
|
||||||
|
QueryStore *queries.Store
|
||||||
|
)
|
||||||
|
|
||||||
|
// DB returns a database connection from the client context
|
||||||
|
func DB(c *gin.Context) *client.Client {
|
||||||
|
if command.Opts.Sessions {
|
||||||
|
return DbSessions.Get(getSessionId(c.Request))
|
||||||
|
}
|
||||||
|
return DbClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// setClient sets the database client connection for the sessions
|
||||||
|
func setClient(c *gin.Context, newClient *client.Client) error {
|
||||||
|
currentClient := DB(c)
|
||||||
|
if currentClient != nil {
|
||||||
|
currentClient.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if !command.Opts.Sessions {
|
||||||
|
DbClient = newClient
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sid := getSessionId(c.Request)
|
||||||
|
if sid == "" {
|
||||||
|
return errSessionRequired
|
||||||
|
}
|
||||||
|
|
||||||
|
DbSessions.Add(sid, newClient)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHome renders the home page
|
||||||
|
func GetHome(prefix string) http.Handler {
|
||||||
|
if prefix != "" {
|
||||||
|
prefix = "/" + prefix
|
||||||
|
}
|
||||||
|
return http.StripPrefix(prefix, static.GetHandler())
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAssets(prefix string) http.Handler {
|
||||||
|
if prefix != "" {
|
||||||
|
prefix = "/" + prefix + "static/"
|
||||||
|
} else {
|
||||||
|
prefix = "/static/"
|
||||||
|
}
|
||||||
|
return http.StripPrefix(prefix, static.GetHandler())
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSessions renders the number of active sessions
|
||||||
|
func GetSessions(c *gin.Context) {
|
||||||
|
// In debug mode endpoint will return a lot of sensitive information
|
||||||
|
// like full database connection string and all query history.
|
||||||
|
if command.Opts.Debug {
|
||||||
|
successResponse(c, DbSessions.Sessions())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
successResponse(c, gin.H{"sessions": DbSessions.Len()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnectWithBackend creates a new connection based on backend resource
|
||||||
|
func ConnectWithBackend(c *gin.Context) {
|
||||||
|
// Setup a new backend client
|
||||||
|
backend := Backend{
|
||||||
|
Endpoint: command.Opts.ConnectBackend,
|
||||||
|
Token: command.Opts.ConnectToken,
|
||||||
|
PassHeaders: strings.Split(command.Opts.ConnectHeaders, ","),
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Fetch connection credentials
|
||||||
|
cred, err := backend.FetchCredential(ctx, c.Param("resource"), c)
|
||||||
|
if err != nil {
|
||||||
|
badRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make the new session
|
||||||
|
sid, err := securerandom.Uuid()
|
||||||
|
if err != nil {
|
||||||
|
badRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Request.Header.Add("x-session-id", sid)
|
||||||
|
|
||||||
|
// Connect to the database
|
||||||
|
cl, err := client.NewFromUrl(cred.DatabaseURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
badRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cl.External = true
|
||||||
|
|
||||||
|
// Finalize session seetup
|
||||||
|
_, err = cl.Info()
|
||||||
|
if err == nil {
|
||||||
|
err = setClient(c, cl)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
cl.Close()
|
||||||
|
badRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
redirectURI := fmt.Sprintf("/%s?session=%s", command.Opts.Prefix, sid)
|
||||||
|
c.Redirect(302, redirectURI)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect creates a new client connection
|
||||||
|
func Connect(c *gin.Context) {
|
||||||
|
if command.Opts.LockSession {
|
||||||
|
badRequest(c, errSessionLocked)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
cl *client.Client
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
if bookmarkID := c.Request.FormValue("bookmark_id"); bookmarkID != "" {
|
||||||
|
cl, err = ConnectWithBookmark(bookmarkID)
|
||||||
|
} else {
|
||||||
|
cl, err = ConnectWithURL(c)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
badRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cl.Test()
|
||||||
|
if err != nil {
|
||||||
|
badRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := cl.Info()
|
||||||
|
if err == nil {
|
||||||
|
err = setClient(c, cl)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
cl.Close()
|
||||||
|
badRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(c, info.Format()[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConnectWithURL(c *gin.Context) (*client.Client, error) {
|
||||||
|
url := c.Request.FormValue("url")
|
||||||
|
if url == "" {
|
||||||
|
return nil, errURLRequired
|
||||||
|
}
|
||||||
|
|
||||||
|
url, err := connection.FormatURL(command.Options{
|
||||||
|
URL: url,
|
||||||
|
Passfile: command.Opts.Passfile,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var sshInfo *shared.SSHInfo
|
||||||
|
if c.Request.FormValue("ssh") != "" {
|
||||||
|
sshInfo = parseSshInfo(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.NewFromUrl(url, sshInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConnectWithBookmark(id string) (*client.Client, error) {
|
||||||
|
manager := bookmarks.NewManager(command.Opts.BookmarksDir)
|
||||||
|
|
||||||
|
bookmark, err := manager.Get(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.NewFromBookmark(bookmark)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SwitchDb perform database switch for the client connection
|
||||||
|
func SwitchDb(c *gin.Context) {
|
||||||
|
if command.Opts.LockSession {
|
||||||
|
badRequest(c, errSessionLocked)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := c.Request.URL.Query().Get("db")
|
||||||
|
if name == "" {
|
||||||
|
name = c.Request.FormValue("db")
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
badRequest(c, errDatabaseNameRequired)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
conn := DB(c)
|
||||||
|
if conn == nil {
|
||||||
|
badRequest(c, errNotConnected)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do not allow switching databases for connections from third-party backends
|
||||||
|
if conn.External {
|
||||||
|
badRequest(c, errSessionLocked)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentURL, err := neturl.Parse(conn.ConnectionString)
|
||||||
|
if err != nil {
|
||||||
|
badRequest(c, errInvalidConnString)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
currentURL.Path = name
|
||||||
|
|
||||||
|
cl, err := client.NewFromUrl(currentURL.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
badRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cl.Test()
|
||||||
|
if err != nil {
|
||||||
|
badRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := cl.Info()
|
||||||
|
if err == nil {
|
||||||
|
err = setClient(c, cl)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
cl.Close()
|
||||||
|
badRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.Close()
|
||||||
|
|
||||||
|
successResponse(c, info.Format()[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disconnect closes the current database connection
|
||||||
|
func Disconnect(c *gin.Context) {
|
||||||
|
if command.Opts.LockSession {
|
||||||
|
badRequest(c, errSessionLocked)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if command.Opts.Sessions {
|
||||||
|
result := DbSessions.Remove(getSessionId(c.Request))
|
||||||
|
successResponse(c, gin.H{"success": result})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
conn := DB(c)
|
||||||
|
if conn == nil {
|
||||||
|
badRequest(c, errNotConnected)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := conn.Close()
|
||||||
|
if err != nil {
|
||||||
|
badRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(c, gin.H{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunQuery executes the query
|
||||||
|
func RunQuery(c *gin.Context) {
|
||||||
|
query := cleanQuery(c.Request.FormValue("query"))
|
||||||
|
|
||||||
|
if query == "" {
|
||||||
|
badRequest(c, errQueryRequired)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
HandleQuery(query, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExplainQuery renders query explain plan
|
||||||
|
func ExplainQuery(c *gin.Context) {
|
||||||
|
query := cleanQuery(c.Request.FormValue("query"))
|
||||||
|
|
||||||
|
if query == "" {
|
||||||
|
badRequest(c, errQueryRequired)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
HandleQuery(fmt.Sprintf("EXPLAIN %s", query), c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnalyzeQuery renders query explain plan and analyze profile
|
||||||
|
func AnalyzeQuery(c *gin.Context) {
|
||||||
|
query := cleanQuery(c.Request.FormValue("query"))
|
||||||
|
|
||||||
|
if query == "" {
|
||||||
|
badRequest(c, errQueryRequired)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
HandleQuery(fmt.Sprintf("EXPLAIN ANALYZE %s", query), c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDatabases renders a list of all databases on the server
|
||||||
|
func GetDatabases(c *gin.Context) {
|
||||||
|
if command.Opts.LockSession {
|
||||||
|
serveResult(c, []string{}, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conn := DB(c)
|
||||||
|
if conn.External {
|
||||||
|
errorResponse(c, 403, errNotPermitted)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
names, err := DB(c).Databases()
|
||||||
|
serveResult(c, names, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetObjects renders a list of database objects
|
||||||
|
func GetObjects(c *gin.Context) {
|
||||||
|
result, err := DB(c).Objects()
|
||||||
|
if err != nil {
|
||||||
|
badRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
successResponse(c, client.ObjectsFromResult(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSchemas renders list of available schemas
|
||||||
|
func GetSchemas(c *gin.Context) {
|
||||||
|
res, err := DB(c).Schemas()
|
||||||
|
serveResult(c, res, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTable renders table information
|
||||||
|
func GetTable(c *gin.Context) {
|
||||||
|
var (
|
||||||
|
res *client.Result
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
db := DB(c)
|
||||||
|
tableName := c.Params.ByName("table")
|
||||||
|
|
||||||
|
switch c.Request.FormValue("type") {
|
||||||
|
case client.ObjTypeMaterializedView:
|
||||||
|
res, err = db.MaterializedView(tableName)
|
||||||
|
case client.ObjTypeFunction:
|
||||||
|
res, err = db.Function(tableName)
|
||||||
|
default:
|
||||||
|
res, err = db.Table(tableName)
|
||||||
|
}
|
||||||
|
|
||||||
|
serveResult(c, res, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTableRows renders table rows
|
||||||
|
func GetTableRows(c *gin.Context) {
|
||||||
|
offset, err := parseIntFormValue(c, "offset", 0)
|
||||||
|
if err != nil {
|
||||||
|
badRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
limit, err := parseIntFormValue(c, "limit", 100)
|
||||||
|
if err != nil {
|
||||||
|
badRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := client.RowsOptions{
|
||||||
|
Limit: limit,
|
||||||
|
Offset: offset,
|
||||||
|
SortColumn: c.Request.FormValue("sort_column"),
|
||||||
|
SortOrder: c.Request.FormValue("sort_order"),
|
||||||
|
Where: c.Request.FormValue("where"),
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := DB(c).TableRows(c.Params.ByName("table"), opts)
|
||||||
|
if err != nil {
|
||||||
|
badRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
countRes, err := DB(c).TableRowsCount(c.Params.ByName("table"), opts)
|
||||||
|
if err != nil {
|
||||||
|
badRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
numFetch := int64(opts.Limit)
|
||||||
|
numOffset := int64(opts.Offset)
|
||||||
|
numRows := countRes.Rows[0][0].(int64)
|
||||||
|
numPages := numRows / numFetch
|
||||||
|
|
||||||
|
if numPages*numFetch < numRows {
|
||||||
|
numPages++
|
||||||
|
}
|
||||||
|
|
||||||
|
res.Pagination = &client.Pagination{
|
||||||
|
Rows: numRows,
|
||||||
|
Page: (numOffset / numFetch) + 1,
|
||||||
|
Pages: numPages,
|
||||||
|
PerPage: numFetch,
|
||||||
|
}
|
||||||
|
|
||||||
|
serveResult(c, res, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTableInfo renders a selected table information
|
||||||
|
func GetTableInfo(c *gin.Context) {
|
||||||
|
res, err := DB(c).TableInfo(c.Params.ByName("table"))
|
||||||
|
if err == nil {
|
||||||
|
successResponse(c, res.Format()[0])
|
||||||
|
} else {
|
||||||
|
badRequest(c, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHistory renders a list of recent queries
|
||||||
|
func GetHistory(c *gin.Context) {
|
||||||
|
successResponse(c, DB(c).History)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConnectionInfo renders information about current connection
|
||||||
|
func GetConnectionInfo(c *gin.Context) {
|
||||||
|
conn := DB(c)
|
||||||
|
|
||||||
|
if err := conn.TestWithTimeout(5 * time.Second); err != nil {
|
||||||
|
badRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := conn.Info()
|
||||||
|
if err != nil {
|
||||||
|
badRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
info := res.Format()[0]
|
||||||
|
info["session_lock"] = command.Opts.LockSession
|
||||||
|
|
||||||
|
successResponse(c, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetActivity renders a list of running queries
|
||||||
|
func GetActivity(c *gin.Context) {
|
||||||
|
res, err := DB(c).Activity()
|
||||||
|
serveResult(c, res, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTableIndexes renders a list of database table indexes
|
||||||
|
func GetTableIndexes(c *gin.Context) {
|
||||||
|
res, err := DB(c).TableIndexes(c.Params.ByName("table"))
|
||||||
|
serveResult(c, res, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTableConstraints renders a list of database constraints
|
||||||
|
func GetTableConstraints(c *gin.Context) {
|
||||||
|
res, err := DB(c).TableConstraints(c.Params.ByName("table"))
|
||||||
|
serveResult(c, res, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTablesStats renders data sizes and estimated rows for all tables in the database
|
||||||
|
func GetTablesStats(c *gin.Context) {
|
||||||
|
res, err := DB(c).TablesStats()
|
||||||
|
serveResult(c, res, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleQuery runs the database query
|
||||||
|
func HandleQuery(query string, c *gin.Context) {
|
||||||
|
metrics.IncrementQueriesCount()
|
||||||
|
|
||||||
|
rawQuery, err := base64.StdEncoding.DecodeString(desanitize64(query))
|
||||||
|
if err == nil {
|
||||||
|
query = string(rawQuery)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := DB(c).Query(query)
|
||||||
|
if err != nil {
|
||||||
|
badRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
format := getQueryParam(c, "format")
|
||||||
|
filename := getQueryParam(c, "filename")
|
||||||
|
|
||||||
|
if filename == "" {
|
||||||
|
filename = fmt.Sprintf("pgweb-%v.%v", time.Now().Unix(), format)
|
||||||
|
}
|
||||||
|
|
||||||
|
if format != "" {
|
||||||
|
c.Writer.Header().Set("Content-disposition", "attachment;filename="+filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch format {
|
||||||
|
case "csv":
|
||||||
|
c.Data(200, "text/csv", result.CSV())
|
||||||
|
case "json":
|
||||||
|
c.Data(200, "application/json", result.JSON())
|
||||||
|
case "xml":
|
||||||
|
c.XML(200, result)
|
||||||
|
default:
|
||||||
|
c.JSON(200, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBookmarks renders the list of available bookmarks
|
||||||
|
func GetBookmarks(c *gin.Context) {
|
||||||
|
manager := bookmarks.NewManager(command.Opts.BookmarksDir)
|
||||||
|
ids, err := manager.ListIDs()
|
||||||
|
serveResult(c, ids, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetInfo renders the pgweb system information
|
||||||
|
func GetInfo(c *gin.Context) {
|
||||||
|
successResponse(c, gin.H{
|
||||||
|
"app": command.Info,
|
||||||
|
"features": gin.H{
|
||||||
|
"session_lock": command.Opts.LockSession,
|
||||||
|
"query_timeout": command.Opts.QueryTimeout,
|
||||||
|
"local_queries": QueryStore != nil,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DataExport performs database table export
|
||||||
|
func DataExport(c *gin.Context) {
|
||||||
|
db := DB(c)
|
||||||
|
|
||||||
|
info, err := db.Info()
|
||||||
|
if err != nil {
|
||||||
|
badRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dump := client.Dump{
|
||||||
|
Table: strings.TrimSpace(c.Request.FormValue("table")),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform validation of pg_dump command availability and compatibility.
|
||||||
|
// Must be done before the actual command is executed to display errors.
|
||||||
|
if err := dump.Validate(db.ServerVersion()); err != nil {
|
||||||
|
badRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
formattedInfo := info.Format()[0]
|
||||||
|
filename := formattedInfo["current_database"].(string)
|
||||||
|
if dump.Table != "" {
|
||||||
|
filename = filename + "_" + dump.Table
|
||||||
|
}
|
||||||
|
|
||||||
|
filename = sanitizeFilename(filename)
|
||||||
|
filename = fmt.Sprintf("%s_%s", filename, time.Now().Format("20060102_150405"))
|
||||||
|
|
||||||
|
c.Header(
|
||||||
|
"Content-Disposition",
|
||||||
|
fmt.Sprintf(`attachment; filename="%s.sql.gz"`, filename),
|
||||||
|
)
|
||||||
|
|
||||||
|
err = dump.Export(c.Request.Context(), db.ConnectionString, c.Writer)
|
||||||
|
if err != nil {
|
||||||
|
logger.WithError(err).Error("pg_dump command failed")
|
||||||
|
badRequest(c, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFunction renders function information
|
||||||
|
func GetFunction(c *gin.Context) {
|
||||||
|
res, err := DB(c).Function(c.Param("id"))
|
||||||
|
serveResult(c, res, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetLocalQueries(c *gin.Context) {
|
||||||
|
connCtx, err := DB(c).GetConnContext()
|
||||||
|
if err != nil {
|
||||||
|
badRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
storeQueries, err := QueryStore.ReadAll()
|
||||||
|
if err != nil {
|
||||||
|
badRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
queries := []localQuery{}
|
||||||
|
for _, q := range storeQueries {
|
||||||
|
if !q.IsPermitted(connCtx.Host, connCtx.User, connCtx.Database, connCtx.Mode) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
queries = append(queries, localQuery{
|
||||||
|
ID: q.ID,
|
||||||
|
Title: q.Meta.Title,
|
||||||
|
Description: q.Meta.Description,
|
||||||
|
Query: cleanQuery(q.Data),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(c, queries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func RunLocalQuery(c *gin.Context) {
|
||||||
|
query, err := QueryStore.Read(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
if err == queries.ErrQueryFileNotExist {
|
||||||
|
query = nil
|
||||||
|
} else {
|
||||||
|
badRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if query == nil {
|
||||||
|
errorResponse(c, 404, "query not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
connCtx, err := DB(c).GetConnContext()
|
||||||
|
if err != nil {
|
||||||
|
badRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !query.IsPermitted(connCtx.Host, connCtx.User, connCtx.Database, connCtx.Mode) {
|
||||||
|
errorResponse(c, 404, "query not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Request.Method == http.MethodGet {
|
||||||
|
successResponse(c, localQuery{
|
||||||
|
ID: query.ID,
|
||||||
|
Title: query.Meta.Title,
|
||||||
|
Description: query.Meta.Description,
|
||||||
|
Query: query.Data,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
statement := cleanQuery(query.Data)
|
||||||
|
if statement == "" {
|
||||||
|
badRequest(c, errQueryRequired)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
HandleQuery(statement, c)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_assetContentType(t *testing.T) {
|
||||||
|
samples := map[string]string{
|
||||||
|
"foo.html": "text/html; charset=utf-8",
|
||||||
|
"foo.css": "text/css; charset=utf-8",
|
||||||
|
"foo.js": "application/javascript",
|
||||||
|
"foo.icon": "image-x-icon",
|
||||||
|
"foo.png": "image/png",
|
||||||
|
"foo.jpg": "image/jpeg",
|
||||||
|
"foo.gif": "image/gif",
|
||||||
|
"foo.eot": "application/vnd.ms-fontobject",
|
||||||
|
"foo.svg": "image/svg+xml",
|
||||||
|
"foo.foo": "text/plain; charset=utf-8",
|
||||||
|
"foo": "text/plain; charset=utf-8",
|
||||||
|
}
|
||||||
|
|
||||||
|
alternatives := map[string]string{
|
||||||
|
"foo.js": "text/javascript; charset=utf-8",
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, expected := range samples {
|
||||||
|
if alternatives[name] == "" {
|
||||||
|
assert.Equal(t, expected, assetContentType(name))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
actual := assetContentType(name)
|
||||||
|
|
||||||
|
if actual != expected && actual != alternatives[name] {
|
||||||
|
t.Errorf("expected %v but got %v (alternative value failed)", expected, actual)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Backend represents a third party configuration source
|
||||||
|
type Backend struct {
|
||||||
|
Endpoint string
|
||||||
|
Token string
|
||||||
|
PassHeaders []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackendRequest represents a payload sent to the third-party source
|
||||||
|
type BackendRequest struct {
|
||||||
|
Resource string `json:"resource"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
Headers map[string]string `json:"headers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackendCredential represents the third-party response
|
||||||
|
type BackendCredential struct {
|
||||||
|
DatabaseURL string `json:"database_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchCredential sends an authentication request to a third-party service
|
||||||
|
func (be Backend) FetchCredential(ctx context.Context, resource string, c *gin.Context) (*BackendCredential, error) {
|
||||||
|
logger.WithField("resource", resource).Debug("fetching database credential")
|
||||||
|
|
||||||
|
request := BackendRequest{
|
||||||
|
Resource: resource,
|
||||||
|
Token: be.Token,
|
||||||
|
Headers: map[string]string{},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass white-listed client headers to the backend request
|
||||||
|
for _, name := range be.PassHeaders {
|
||||||
|
request.Headers[strings.ToLower(name)] = c.Request.Header.Get(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(request)
|
||||||
|
if err != nil {
|
||||||
|
logger.WithField("resource", resource).Error("backend request serialization error:", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, be.Endpoint, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("content-type", "application/json")
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
logger.WithField("resource", resource).Error("backend credential fetch failed:", err)
|
||||||
|
return nil, errBackendConnectError
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
err = fmt.Errorf("backend credential fetch received HTTP status code %v", resp.StatusCode)
|
||||||
|
|
||||||
|
logger.
|
||||||
|
WithField("resource", resource).
|
||||||
|
WithField("status", resp.StatusCode).
|
||||||
|
Error(err)
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cred := &BackendCredential{}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(cred); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if cred.DatabaseURL == "" {
|
||||||
|
return nil, errConnStringRequired
|
||||||
|
}
|
||||||
|
|
||||||
|
return cred, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBackendFetchCredential(t *testing.T) {
|
||||||
|
examples := []struct {
|
||||||
|
name string
|
||||||
|
backend Backend
|
||||||
|
resourceName string
|
||||||
|
cred *BackendCredential
|
||||||
|
reqCtx *gin.Context
|
||||||
|
ctx func() (context.Context, context.CancelFunc)
|
||||||
|
err error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Bad auth token",
|
||||||
|
backend: Backend{Endpoint: "http://localhost:5555/unauthorized"},
|
||||||
|
err: errors.New("backend credential fetch received HTTP status code 401"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Backend timeout",
|
||||||
|
backend: Backend{Endpoint: "http://localhost:5555/timeout"},
|
||||||
|
ctx: func() (context.Context, context.CancelFunc) {
|
||||||
|
return context.WithTimeout(context.Background(), time.Millisecond*100)
|
||||||
|
},
|
||||||
|
err: errors.New("Unable to connect to the auth backend"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Empty response",
|
||||||
|
backend: Backend{Endpoint: "http://localhost:5555/empty-response"},
|
||||||
|
err: errors.New("Connection string is required"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Missing header",
|
||||||
|
backend: Backend{Endpoint: "http://localhost:5555/pass-header"},
|
||||||
|
err: errors.New("backend credential fetch received HTTP status code 400"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Require header",
|
||||||
|
backend: Backend{
|
||||||
|
Endpoint: "http://localhost:5555/pass-header",
|
||||||
|
PassHeaders: []string{"x-foo"},
|
||||||
|
},
|
||||||
|
reqCtx: &gin.Context{
|
||||||
|
Request: &http.Request{
|
||||||
|
Header: http.Header{
|
||||||
|
"X-Foo": []string{"bar"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cred: &BackendCredential{DatabaseURL: "postgres://hostname/bar"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Success",
|
||||||
|
backend: Backend{Endpoint: "http://localhost:5555/success"},
|
||||||
|
cred: &BackendCredential{DatabaseURL: "postgres://hostname/dbname"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
srvCtx, srvCancel := context.WithTimeout(context.Background(), time.Minute)
|
||||||
|
defer srvCancel()
|
||||||
|
|
||||||
|
startTestBackend(srvCtx, "localhost:5555")
|
||||||
|
|
||||||
|
for _, ex := range examples {
|
||||||
|
t.Run(ex.name, func(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
if ex.ctx != nil {
|
||||||
|
ctx, cancel = ex.ctx()
|
||||||
|
}
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
reqCtx := ex.reqCtx
|
||||||
|
if reqCtx == nil {
|
||||||
|
reqCtx = &gin.Context{
|
||||||
|
Request: &http.Request{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cred, err := ex.backend.FetchCredential(ctx, ex.resourceName, reqCtx)
|
||||||
|
assert.Equal(t, ex.err, err)
|
||||||
|
assert.Equal(t, ex.cred, cred)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startTestBackend(ctx context.Context, listenAddr string) {
|
||||||
|
router := gin.New()
|
||||||
|
|
||||||
|
router.Use(func(c *gin.Context) {
|
||||||
|
if c.GetHeader("content-type") != "application/json" {
|
||||||
|
c.AbortWithStatus(http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.POST("/unauthorized", func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||||
|
})
|
||||||
|
|
||||||
|
router.POST("/timeout", func(c *gin.Context) {
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
c.JSON(http.StatusOK, gin.H{})
|
||||||
|
})
|
||||||
|
|
||||||
|
router.POST("/empty-response", func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{})
|
||||||
|
})
|
||||||
|
|
||||||
|
router.POST("/pass-header", func(c *gin.Context) {
|
||||||
|
req := BackendRequest{}
|
||||||
|
if err := c.BindJSON(&req); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
header := req.Headers["x-foo"]
|
||||||
|
if header == "" {
|
||||||
|
c.AbortWithStatus(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"database_url": "postgres://hostname/" + header,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
router.POST("/success", func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"database_url": "postgres://hostname/dbname",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
server := &http.Server{Addr: listenAddr, Handler: router}
|
||||||
|
mustStartServer(server)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
if err := server.Shutdown(context.Background()); err != nil && err != http.ErrServerClosed {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustStartServer(server *http.Server) {
|
||||||
|
go func() {
|
||||||
|
err := server.ListenAndServe()
|
||||||
|
if err != nil && err != http.ErrServerClosed {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := waitForServer(server.Addr, 5); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForServer(addr string, n int) error {
|
||||||
|
var lastErr error
|
||||||
|
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
conn, err := net.Dial("tcp", addr)
|
||||||
|
if err == nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lastErr = err
|
||||||
|
time.Sleep(time.Millisecond * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errNotConnected = errors.New("Not connected")
|
||||||
|
errNotPermitted = errors.New("Not permitted")
|
||||||
|
errConnStringRequired = errors.New("Connection string is required")
|
||||||
|
errInvalidConnString = errors.New("Invalid connection string")
|
||||||
|
errSessionRequired = errors.New("Session ID is required")
|
||||||
|
errSessionLocked = errors.New("Session is locked")
|
||||||
|
errURLRequired = errors.New("URL parameter is required")
|
||||||
|
errQueryRequired = errors.New("Query parameter is required")
|
||||||
|
errDatabaseNameRequired = errors.New("Database name is required")
|
||||||
|
errBackendConnectError = errors.New("Unable to connect to the auth backend")
|
||||||
|
)
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/sosedoff/pgweb/pkg/shared"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Mime types definitions
|
||||||
|
extraMimeTypes = map[string]string{
|
||||||
|
".icon": "image-x-icon",
|
||||||
|
".ttf": "application/x-font-ttf",
|
||||||
|
".woff": "application/x-font-woff",
|
||||||
|
".eot": "application/vnd.ms-fontobject",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".html": "text/html; charset-utf-8",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paths that dont require database connection
|
||||||
|
allowedPaths = map[string]bool{
|
||||||
|
"/api/sessions": true,
|
||||||
|
"/api/info": true,
|
||||||
|
"/api/connect": true,
|
||||||
|
"/api/bookmarks": true,
|
||||||
|
"/api/history": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// List of characters replaced by javascript code to make queries url-safe.
|
||||||
|
base64subs = map[string]string{
|
||||||
|
"-": "+",
|
||||||
|
"_": "/",
|
||||||
|
".": "=",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular expression to remove unwanted characters in filenames
|
||||||
|
regexCleanFilename = regexp.MustCompile(`[^\w]+`)
|
||||||
|
)
|
||||||
|
|
||||||
|
type Error struct {
|
||||||
|
Message string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewError(err error) Error {
|
||||||
|
return Error{err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns a clean query without any comment statements
|
||||||
|
func cleanQuery(query string) string {
|
||||||
|
lines := []string{}
|
||||||
|
|
||||||
|
for _, line := range strings.Split(query, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(line, "--") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(strings.Join(lines, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func desanitize64(query string) string {
|
||||||
|
// Before feeding the string into decoded, we must "reconstruct" the base64 data.
|
||||||
|
// Javascript replaces a few characters to be url-safe.
|
||||||
|
for olds, news := range base64subs {
|
||||||
|
query = strings.Replace(query, olds, news, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeFilename(str string) string {
|
||||||
|
str = strings.ReplaceAll(str, ".", "_")
|
||||||
|
return regexCleanFilename.ReplaceAllString(str, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSessionId(req *http.Request) string {
|
||||||
|
id := req.Header.Get("x-session-id")
|
||||||
|
if id == "" {
|
||||||
|
id = req.URL.Query().Get("_session_id")
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func getQueryParam(c *gin.Context, name string) string {
|
||||||
|
result := ""
|
||||||
|
q := c.Request.URL.Query()
|
||||||
|
|
||||||
|
if len(q[name]) > 0 {
|
||||||
|
result = q[name][0]
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseIntFormValue(c *gin.Context, name string, defValue int) (int, error) {
|
||||||
|
val := c.Request.FormValue(name)
|
||||||
|
|
||||||
|
if val == "" {
|
||||||
|
return defValue, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
num, err := strconv.Atoi(val)
|
||||||
|
if err != nil {
|
||||||
|
return defValue, fmt.Errorf("%s must be a number", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if num < 1 && defValue != 0 {
|
||||||
|
return defValue, fmt.Errorf("%s must be greater than 0", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return num, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSshInfo(c *gin.Context) *shared.SSHInfo {
|
||||||
|
info := shared.SSHInfo{
|
||||||
|
Host: c.Request.FormValue("ssh_host"),
|
||||||
|
Port: c.Request.FormValue("ssh_port"),
|
||||||
|
User: c.Request.FormValue("ssh_user"),
|
||||||
|
Password: c.Request.FormValue("ssh_password"),
|
||||||
|
Key: c.Request.FormValue("ssh_key"),
|
||||||
|
KeyPassword: c.Request.FormValue("ssh_key_password"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.Port == "" {
|
||||||
|
info.Port = "22"
|
||||||
|
}
|
||||||
|
|
||||||
|
return &info
|
||||||
|
}
|
||||||
|
|
||||||
|
func assetContentType(name string) string {
|
||||||
|
ext := filepath.Ext(name)
|
||||||
|
result := mime.TypeByExtension(ext)
|
||||||
|
|
||||||
|
if result == "" {
|
||||||
|
result = extraMimeTypes[ext]
|
||||||
|
}
|
||||||
|
|
||||||
|
if result == "" {
|
||||||
|
result = "text/plain; charset=utf-8"
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send a query result to client
|
||||||
|
func serveResult(c *gin.Context, result interface{}, err interface{}) {
|
||||||
|
if err == nil {
|
||||||
|
successResponse(c, result)
|
||||||
|
} else {
|
||||||
|
badRequest(c, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send successful response back to client
|
||||||
|
func successResponse(c *gin.Context, data interface{}) {
|
||||||
|
c.JSON(200, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send an error response back to client
|
||||||
|
func errorResponse(c *gin.Context, status int, err interface{}) {
|
||||||
|
var message interface{}
|
||||||
|
|
||||||
|
switch v := err.(type) {
|
||||||
|
case error:
|
||||||
|
message = v.Error()
|
||||||
|
case string:
|
||||||
|
message = v
|
||||||
|
default:
|
||||||
|
message = v
|
||||||
|
}
|
||||||
|
|
||||||
|
c.AbortWithStatusJSON(status, gin.H{"status": status, "error": message})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send a bad request (http 400) back to client
|
||||||
|
func badRequest(c *gin.Context, err interface{}) {
|
||||||
|
errorResponse(c, 400, err)
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_desanitize64(t *testing.T) {
|
||||||
|
examples := map[string]string{
|
||||||
|
"test": "test",
|
||||||
|
"test+test+": "test-test-",
|
||||||
|
"test/test/": "test_test_",
|
||||||
|
"test=test==": "test.test..",
|
||||||
|
}
|
||||||
|
|
||||||
|
for expected, example := range examples {
|
||||||
|
assert.Equal(t, expected, desanitize64(example))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_cleanQuery(t *testing.T) {
|
||||||
|
assert.Equal(t, "a\nb\nc", cleanQuery("a\nb\nc"))
|
||||||
|
assert.Equal(t, "", cleanQuery("--something"))
|
||||||
|
assert.Equal(t, "test", cleanQuery("--test\ntest\n -- test\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_sanitizeFilename(t *testing.T) {
|
||||||
|
examples := map[string]string{
|
||||||
|
"foo": "foo",
|
||||||
|
"fooBar": "fooBar",
|
||||||
|
"foo.bar": "foo_bar",
|
||||||
|
`"foo"."bar"`: "foo_bar",
|
||||||
|
"!@#$foo.&&*(&bar": "foo_bar",
|
||||||
|
}
|
||||||
|
|
||||||
|
for given, expected := range examples {
|
||||||
|
t.Run(given, func(t *testing.T) {
|
||||||
|
assert.Equal(t, expected, sanitizeFilename(given))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_getSessionId(t *testing.T) {
|
||||||
|
req := &http.Request{Header: http.Header{}}
|
||||||
|
req.Header.Add("x-session-id", "token")
|
||||||
|
assert.Equal(t, "token", getSessionId(req))
|
||||||
|
|
||||||
|
req = &http.Request{}
|
||||||
|
req.URL, _ = url.Parse("http://foobar/?_session_id=token")
|
||||||
|
assert.Equal(t, "token", getSessionId(req))
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_serveResult(t *testing.T) {
|
||||||
|
server := gin.Default()
|
||||||
|
server.GET("/good", func(c *gin.Context) {
|
||||||
|
serveResult(c, gin.H{"foo": "bar"}, nil)
|
||||||
|
})
|
||||||
|
server.GET("/bad", func(c *gin.Context) {
|
||||||
|
serveResult(c, nil, errors.New("message"))
|
||||||
|
})
|
||||||
|
server.GET("/nodata", func(c *gin.Context) {
|
||||||
|
serveResult(c, nil, nil)
|
||||||
|
})
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/good", nil)
|
||||||
|
server.ServeHTTP(w, req)
|
||||||
|
assert.Equal(t, 200, w.Code)
|
||||||
|
assert.Equal(t, `{"foo":"bar"}`, w.Body.String())
|
||||||
|
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
req, _ = http.NewRequest("GET", "/bad", nil)
|
||||||
|
server.ServeHTTP(w, req)
|
||||||
|
assert.Equal(t, 400, w.Code)
|
||||||
|
assert.Equal(t, `{"error":"message","status":400}`, w.Body.String())
|
||||||
|
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
req, _ = http.NewRequest("GET", "/nodata", nil)
|
||||||
|
server.ServeHTTP(w, req)
|
||||||
|
assert.Equal(t, 200, w.Code)
|
||||||
|
assert.Equal(t, `null`, w.Body.String())
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"github.com/sosedoff/pgweb/pkg/command"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
logger *logrus.Logger
|
||||||
|
|
||||||
|
reConnectToken = regexp.MustCompile("/connect/(.*)")
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
if logger == nil {
|
||||||
|
logger = logrus.New()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Move this into server struct when it's ready
|
||||||
|
func SetLogger(l *logrus.Logger) {
|
||||||
|
logger = l
|
||||||
|
}
|
||||||
|
|
||||||
|
func RequestLogger(logger *logrus.Logger) gin.HandlerFunc {
|
||||||
|
debug := logger.Level > logrus.InfoLevel
|
||||||
|
logForwardedUser := command.Opts.LogForwardedUser
|
||||||
|
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
start := time.Now()
|
||||||
|
path := c.Request.URL.Path
|
||||||
|
|
||||||
|
// Process request
|
||||||
|
c.Next()
|
||||||
|
|
||||||
|
if !debug {
|
||||||
|
// Skip static assets logging
|
||||||
|
if strings.Contains(path, "/static/") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
path = sanitizeLogPath(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
status := c.Writer.Status()
|
||||||
|
end := time.Now()
|
||||||
|
latency := end.Sub(start)
|
||||||
|
|
||||||
|
fields := logrus.Fields{
|
||||||
|
"status": status,
|
||||||
|
"method": c.Request.Method,
|
||||||
|
"remote_addr": c.ClientIP(),
|
||||||
|
"duration": latency.String(),
|
||||||
|
"duration_ms": latency.Milliseconds(),
|
||||||
|
"path": path,
|
||||||
|
}
|
||||||
|
|
||||||
|
if reqID := getRequestID(c); reqID != "" {
|
||||||
|
fields["id"] = reqID
|
||||||
|
}
|
||||||
|
|
||||||
|
if logForwardedUser {
|
||||||
|
if forwardedUser := c.GetHeader("X-Forwarded-User"); forwardedUser != "" {
|
||||||
|
fields["forwarded_user"] = forwardedUser
|
||||||
|
}
|
||||||
|
if forwardedEmail := c.GetHeader("X-Forwarded-Email"); forwardedEmail != "" {
|
||||||
|
fields["forwarded_email"] = forwardedEmail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.Errors.Last(); err != nil {
|
||||||
|
fields["error"] = err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional fields for debugging
|
||||||
|
if debug {
|
||||||
|
fields["raw_query"] = c.Request.URL.RawQuery
|
||||||
|
|
||||||
|
if c.Request.Method != http.MethodGet {
|
||||||
|
fields["raw_form"] = c.Request.Form
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := logger.WithFields(fields)
|
||||||
|
msg := "http_request"
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case status >= http.StatusBadRequest && status < http.StatusInternalServerError:
|
||||||
|
entry.Warn(msg)
|
||||||
|
case status >= http.StatusInternalServerError:
|
||||||
|
entry.Error(msg)
|
||||||
|
default:
|
||||||
|
entry.Info(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeLogPath(str string) string {
|
||||||
|
return reConnectToken.ReplaceAllString(str, "/connect/REDACTED")
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRequestID(c *gin.Context) string {
|
||||||
|
id := c.GetHeader("x-request-id")
|
||||||
|
if id == "" {
|
||||||
|
id = c.GetHeader("x-amzn-trace-id")
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_getRequestID(t *testing.T) {
|
||||||
|
examples := []struct {
|
||||||
|
headers map[string]string
|
||||||
|
result string
|
||||||
|
}{
|
||||||
|
{map[string]string{}, ""},
|
||||||
|
{map[string]string{"X-Request-ID": "foo"}, "foo"},
|
||||||
|
{map[string]string{"x-request-id": "foo"}, "foo"},
|
||||||
|
{map[string]string{"x-request-id": "foo"}, "foo"},
|
||||||
|
{map[string]string{"x-request-id": "foo", "x-amzn-trace-id": "amz"}, "foo"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ex := range examples {
|
||||||
|
req := &http.Request{Header: http.Header{}}
|
||||||
|
for k, v := range ex.headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, ex.result, getRequestID(&gin.Context{Request: req}))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/sosedoff/pgweb/pkg/command"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Middleware to check database connection status before running queries
|
||||||
|
func dbCheckMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
path := strings.Replace(c.Request.URL.Path, command.Opts.Prefix, "", -1)
|
||||||
|
|
||||||
|
// Allow whitelisted paths
|
||||||
|
if allowedPaths[path] {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if session exists in single-session mode
|
||||||
|
if !command.Opts.Sessions {
|
||||||
|
if DbClient == nil {
|
||||||
|
badRequest(c, errNotConnected)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine session ID from the client request
|
||||||
|
sid := getSessionId(c.Request)
|
||||||
|
if sid == "" {
|
||||||
|
badRequest(c, errSessionRequired)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine the database connection handle for the session
|
||||||
|
conn := DbSessions.Get(sid)
|
||||||
|
if conn == nil {
|
||||||
|
badRequest(c, errNotConnected)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware to inject CORS headers
|
||||||
|
func corsMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
c.Header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||||
|
c.Header("Access-Control-Expose-Headers", "*")
|
||||||
|
c.Header("Access-Control-Allow-Origin", command.Opts.CorsOrigin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireLocalQueries() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if QueryStore == nil {
|
||||||
|
badRequest(c, "local queries are disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/sosedoff/pgweb/pkg/command"
|
||||||
|
"github.com/sosedoff/pgweb/pkg/metrics"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SetupMiddlewares(group *gin.RouterGroup) {
|
||||||
|
if command.Opts.Cors {
|
||||||
|
group.Use(corsMiddleware())
|
||||||
|
}
|
||||||
|
|
||||||
|
group.Use(dbCheckMiddleware())
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetupRoutes(router *gin.Engine) {
|
||||||
|
root := router.Group(command.Opts.Prefix)
|
||||||
|
|
||||||
|
root.GET("/", gin.WrapH(GetHome(command.Opts.Prefix)))
|
||||||
|
root.GET("/static/*path", gin.WrapH(GetAssets(command.Opts.Prefix)))
|
||||||
|
root.GET("/connect/:resource", ConnectWithBackend)
|
||||||
|
|
||||||
|
api := root.Group("/api")
|
||||||
|
SetupMiddlewares(api)
|
||||||
|
|
||||||
|
if command.Opts.Sessions {
|
||||||
|
api.GET("/sessions", GetSessions)
|
||||||
|
}
|
||||||
|
|
||||||
|
api.GET("/info", GetInfo)
|
||||||
|
api.POST("/connect", Connect)
|
||||||
|
api.POST("/disconnect", Disconnect)
|
||||||
|
api.POST("/switchdb", SwitchDb)
|
||||||
|
api.GET("/databases", GetDatabases)
|
||||||
|
api.GET("/connection", GetConnectionInfo)
|
||||||
|
api.GET("/activity", GetActivity)
|
||||||
|
api.GET("/schemas", GetSchemas)
|
||||||
|
api.GET("/objects", GetObjects)
|
||||||
|
api.GET("/tables/:table", GetTable)
|
||||||
|
api.GET("/tables/:table/rows", GetTableRows)
|
||||||
|
api.GET("/tables/:table/info", GetTableInfo)
|
||||||
|
api.GET("/tables/:table/indexes", GetTableIndexes)
|
||||||
|
api.GET("/tables/:table/constraints", GetTableConstraints)
|
||||||
|
api.GET("/tables_stats", GetTablesStats)
|
||||||
|
api.GET("/functions/:id", GetFunction)
|
||||||
|
api.GET("/query", RunQuery)
|
||||||
|
api.POST("/query", RunQuery)
|
||||||
|
api.GET("/explain", ExplainQuery)
|
||||||
|
api.POST("/explain", ExplainQuery)
|
||||||
|
api.GET("/analyze", AnalyzeQuery)
|
||||||
|
api.POST("/analyze", AnalyzeQuery)
|
||||||
|
api.GET("/history", GetHistory)
|
||||||
|
api.GET("/bookmarks", GetBookmarks)
|
||||||
|
api.GET("/export", DataExport)
|
||||||
|
api.GET("/local_queries", requireLocalQueries(), GetLocalQueries)
|
||||||
|
api.GET("/local_queries/:id", requireLocalQueries(), RunLocalQuery)
|
||||||
|
api.POST("/local_queries/:id", requireLocalQueries(), RunLocalQuery)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetupMetrics(engine *gin.Engine) {
|
||||||
|
if command.Opts.MetricsEnabled && command.Opts.MetricsAddr == "" {
|
||||||
|
engine.GET("/metrics", gin.WrapH(metrics.Handler()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"github.com/sosedoff/pgweb/pkg/client"
|
||||||
|
"github.com/sosedoff/pgweb/pkg/metrics"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SessionManager struct {
|
||||||
|
logger *logrus.Logger
|
||||||
|
sessions map[string]*client.Client
|
||||||
|
mu sync.Mutex
|
||||||
|
idleTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSessionManager(logger *logrus.Logger) *SessionManager {
|
||||||
|
return &SessionManager{
|
||||||
|
logger: logger,
|
||||||
|
sessions: map[string]*client.Client{},
|
||||||
|
mu: sync.Mutex{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *SessionManager) SetIdleTimeout(timeout time.Duration) {
|
||||||
|
m.idleTimeout = timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *SessionManager) IDs() []string {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
ids := []string{}
|
||||||
|
for k := range m.sessions {
|
||||||
|
ids = append(ids, k)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *SessionManager) Sessions() map[string]*client.Client {
|
||||||
|
m.mu.Lock()
|
||||||
|
sessions := m.sessions
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
return sessions
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *SessionManager) Get(id string) *client.Client {
|
||||||
|
m.mu.Lock()
|
||||||
|
c := m.sessions[id]
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *SessionManager) Add(id string, conn *client.Client) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
m.sessions[id] = conn
|
||||||
|
metrics.SetSessionsCount(len(m.sessions))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *SessionManager) Remove(id string) bool {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
conn, ok := m.sessions[id]
|
||||||
|
if ok {
|
||||||
|
conn.Close()
|
||||||
|
delete(m.sessions, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
metrics.SetSessionsCount(len(m.sessions))
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *SessionManager) Len() int {
|
||||||
|
m.mu.Lock()
|
||||||
|
sz := len(m.sessions)
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
return sz
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *SessionManager) Cleanup() int {
|
||||||
|
if m.idleTimeout == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
removed := 0
|
||||||
|
|
||||||
|
m.logger.Debug("starting idle sessions cleanup")
|
||||||
|
defer func() {
|
||||||
|
m.logger.Debug("removed idle sessions:", removed)
|
||||||
|
}()
|
||||||
|
|
||||||
|
for _, id := range m.staleSessions() {
|
||||||
|
m.logger.WithField("id", id).Debug("closing stale session")
|
||||||
|
if m.Remove(id) {
|
||||||
|
removed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return removed
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *SessionManager) RunPeriodicCleanup() {
|
||||||
|
m.logger.WithField("timeout", m.idleTimeout).Info("session manager cleanup enabled")
|
||||||
|
|
||||||
|
for range time.Tick(time.Minute) {
|
||||||
|
m.Cleanup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *SessionManager) staleSessions() []string {
|
||||||
|
m.mu.TryLock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
ids := []string{}
|
||||||
|
|
||||||
|
for id, conn := range m.sessions {
|
||||||
|
if now.Sub(conn.LastQueryTime()) > m.idleTimeout {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"github.com/sosedoff/pgweb/pkg/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSessionManager(t *testing.T) {
|
||||||
|
t.Run("return ids", func(t *testing.T) {
|
||||||
|
manager := NewSessionManager(nil)
|
||||||
|
assert.Equal(t, []string{}, manager.IDs())
|
||||||
|
|
||||||
|
manager.sessions["foo"] = &client.Client{}
|
||||||
|
manager.sessions["bar"] = &client.Client{}
|
||||||
|
|
||||||
|
ids := manager.IDs()
|
||||||
|
sort.Strings(ids)
|
||||||
|
assert.Equal(t, []string{"bar", "foo"}, ids)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("get session", func(t *testing.T) {
|
||||||
|
manager := NewSessionManager(nil)
|
||||||
|
assert.Nil(t, manager.Get("foo"))
|
||||||
|
|
||||||
|
manager.sessions["foo"] = &client.Client{}
|
||||||
|
assert.NotNil(t, manager.Get("foo"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("set session", func(t *testing.T) {
|
||||||
|
manager := NewSessionManager(nil)
|
||||||
|
assert.Nil(t, manager.Get("foo"))
|
||||||
|
|
||||||
|
manager.Add("foo", &client.Client{})
|
||||||
|
assert.NotNil(t, manager.Get("foo"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("remove session", func(t *testing.T) {
|
||||||
|
manager := NewSessionManager(nil)
|
||||||
|
assert.Nil(t, manager.Get("foo"))
|
||||||
|
|
||||||
|
manager.Add("foo", &client.Client{})
|
||||||
|
assert.NotNil(t, manager.Get("foo"))
|
||||||
|
assert.True(t, manager.Remove("foo"))
|
||||||
|
assert.False(t, manager.Remove("foo"))
|
||||||
|
assert.Nil(t, manager.Get("foo"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("return len", func(t *testing.T) {
|
||||||
|
manager := NewSessionManager(nil)
|
||||||
|
manager.sessions["foo"] = &client.Client{}
|
||||||
|
manager.sessions["bar"] = &client.Client{}
|
||||||
|
|
||||||
|
assert.Equal(t, 2, manager.Len())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("clean up stale sessions", func(t *testing.T) {
|
||||||
|
manager := NewSessionManager(logrus.New())
|
||||||
|
conn := &client.Client{}
|
||||||
|
manager.Add("foo", conn)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, manager.Len())
|
||||||
|
assert.Equal(t, 0, manager.Cleanup())
|
||||||
|
assert.Equal(t, 1, manager.Len())
|
||||||
|
|
||||||
|
res, err := conn.Query("select 1")
|
||||||
|
assert.Nil(t, res)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
manager.SetIdleTimeout(time.Minute)
|
||||||
|
assert.Equal(t, 1, manager.Cleanup())
|
||||||
|
assert.Equal(t, 0, manager.Len())
|
||||||
|
assert.True(t, conn.IsClosed())
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
type localQuery struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Query string `json:"query"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package bookmarks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/sosedoff/pgweb/pkg/command"
|
||||||
|
"github.com/sosedoff/pgweb/pkg/shared"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bookmark contains information about bookmarked database connection
|
||||||
|
type Bookmark struct {
|
||||||
|
ID string // ID generated from the filename
|
||||||
|
URL string // Postgres connection URL
|
||||||
|
Host string // Server hostname
|
||||||
|
Port int // Server port
|
||||||
|
User string // Database user
|
||||||
|
UserVar string // Database user environment variable
|
||||||
|
Password string // User password
|
||||||
|
PasswordVar string // User password environment variable
|
||||||
|
Database string // Database name
|
||||||
|
SSLMode string // Connection SSL mode
|
||||||
|
SSH *shared.SSHInfo // SSH tunnel config
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSHInfoIsEmpty returns true if ssh configuration is not provided
|
||||||
|
func (b Bookmark) SSHInfoIsEmpty() bool {
|
||||||
|
return b.SSH == nil || b.SSH.User == "" && b.SSH.Host == "" && b.SSH.Port == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConvertToOptions returns an options struct from connection details
|
||||||
|
func (b Bookmark) ConvertToOptions() command.Options {
|
||||||
|
user := b.User
|
||||||
|
if b.User == "" {
|
||||||
|
user = os.Getenv(b.UserVar)
|
||||||
|
}
|
||||||
|
|
||||||
|
pass := b.Password
|
||||||
|
if b.Password == "" {
|
||||||
|
pass = os.Getenv(b.PasswordVar)
|
||||||
|
}
|
||||||
|
|
||||||
|
return command.Options{
|
||||||
|
URL: b.URL,
|
||||||
|
Host: b.Host,
|
||||||
|
Port: b.Port,
|
||||||
|
User: user,
|
||||||
|
Pass: pass,
|
||||||
|
DbName: b.Database,
|
||||||
|
SSLMode: b.SSLMode,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package bookmarks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sosedoff/pgweb/pkg/command"
|
||||||
|
"github.com/sosedoff/pgweb/pkg/shared"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBookmarkSSHInfoIsEmpty(t *testing.T) {
|
||||||
|
t.Run("empty", func(t *testing.T) {
|
||||||
|
info := &shared.SSHInfo{
|
||||||
|
Host: "",
|
||||||
|
Port: "",
|
||||||
|
User: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
b := Bookmark{SSH: nil}
|
||||||
|
assert.True(t, b.SSHInfoIsEmpty())
|
||||||
|
|
||||||
|
b = Bookmark{SSH: info}
|
||||||
|
assert.True(t, b.SSHInfoIsEmpty())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("populated", func(t *testing.T) {
|
||||||
|
info := &shared.SSHInfo{
|
||||||
|
Host: "localhost",
|
||||||
|
Port: "8080",
|
||||||
|
User: "postgres",
|
||||||
|
}
|
||||||
|
|
||||||
|
b := Bookmark{SSH: info}
|
||||||
|
assert.False(t, b.SSHInfoIsEmpty())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBookmarkWithVarsConvertToOptions(t *testing.T) {
|
||||||
|
t.Run("literals set", func(t *testing.T) {
|
||||||
|
b := Bookmark{
|
||||||
|
User: "user",
|
||||||
|
UserVar: "",
|
||||||
|
Password: "password",
|
||||||
|
PasswordVar: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
expOpt := command.Options{
|
||||||
|
User: "user",
|
||||||
|
Pass: "password",
|
||||||
|
}
|
||||||
|
|
||||||
|
opt := b.ConvertToOptions()
|
||||||
|
assert.Equal(t, expOpt, opt)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("all set", func(t *testing.T) {
|
||||||
|
b := Bookmark{
|
||||||
|
User: "user",
|
||||||
|
UserVar: "DB_USER",
|
||||||
|
Password: "password",
|
||||||
|
PasswordVar: "DB_PASSWORD",
|
||||||
|
}
|
||||||
|
|
||||||
|
expOpt := command.Options{
|
||||||
|
User: "user",
|
||||||
|
Pass: "password",
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("DB_USER", "user123")
|
||||||
|
t.Setenv("DB_PASSWORD", "password123")
|
||||||
|
opt := b.ConvertToOptions()
|
||||||
|
assert.Equal(t, expOpt, opt)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("env vars set", func(t *testing.T) {
|
||||||
|
b := Bookmark{
|
||||||
|
User: "",
|
||||||
|
UserVar: "DB_USER",
|
||||||
|
Password: "",
|
||||||
|
PasswordVar: "DB_PASSWORD",
|
||||||
|
}
|
||||||
|
|
||||||
|
expOpt := command.Options{
|
||||||
|
User: "user123",
|
||||||
|
Pass: "password123",
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("DB_USER", "user123")
|
||||||
|
t.Setenv("DB_PASSWORD", "password123")
|
||||||
|
opt := b.ConvertToOptions()
|
||||||
|
assert.Equal(t, expOpt, opt)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBookmarkConvertToOptions(t *testing.T) {
|
||||||
|
b := Bookmark{
|
||||||
|
URL: "postgres://username:password@host:port/database?sslmode=disable",
|
||||||
|
Host: "localhost",
|
||||||
|
Port: 5432,
|
||||||
|
User: "postgres",
|
||||||
|
Password: "password",
|
||||||
|
Database: "mydatabase",
|
||||||
|
SSLMode: "disable",
|
||||||
|
}
|
||||||
|
|
||||||
|
expOpt := command.Options{
|
||||||
|
URL: "postgres://username:password@host:port/database?sslmode=disable",
|
||||||
|
Host: "localhost",
|
||||||
|
Port: 5432,
|
||||||
|
User: "postgres",
|
||||||
|
Pass: "password",
|
||||||
|
DbName: "mydatabase",
|
||||||
|
SSLMode: "disable",
|
||||||
|
}
|
||||||
|
|
||||||
|
opt := b.ConvertToOptions()
|
||||||
|
assert.Equal(t, expOpt, opt)
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package bookmarks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/BurntSushi/toml"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Manager struct {
|
||||||
|
dir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewManager(dir string) Manager {
|
||||||
|
return Manager{
|
||||||
|
dir: dir,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Manager) Get(id string) (*Bookmark, error) {
|
||||||
|
bookmarks, err := m.list()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, b := range bookmarks {
|
||||||
|
if b.ID == id {
|
||||||
|
return &b, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("bookmark %v not found", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Manager) List() ([]Bookmark, error) {
|
||||||
|
return m.list()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Manager) ListIDs() ([]string, error) {
|
||||||
|
bookmarks, err := m.list()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := make([]string, len(bookmarks))
|
||||||
|
for i, bookmark := range bookmarks {
|
||||||
|
ids[i] = bookmark.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Manager) list() ([]Bookmark, error) {
|
||||||
|
result := []Bookmark{}
|
||||||
|
|
||||||
|
if m.dir == "" {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(m.dir)
|
||||||
|
if err != nil {
|
||||||
|
// Do not fail if base dir does not exists: it's not created by default
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
fmt.Fprintf(os.Stderr, "[WARN] bookmarks dir %s does not exist\n", m.dir)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return nil, fmt.Errorf("path %s is not a directory", m.dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
dirEntries, err := os.ReadDir(m.dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range dirEntries {
|
||||||
|
name := entry.Name()
|
||||||
|
if filepath.Ext(name) != ".toml" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
bookmark, err := readBookmark(filepath.Join(m.dir, name))
|
||||||
|
if err != nil {
|
||||||
|
// Do not fail if one of the bookmarks is invalid
|
||||||
|
fmt.Fprintf(os.Stderr, "[WARN] bookmark file %s is invalid: %s\n", name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result, bookmark)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readBookmark(path string) (Bookmark, error) {
|
||||||
|
bookmark := Bookmark{
|
||||||
|
ID: fileBasename(path),
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
err = fmt.Errorf("bookmark file %s does not exist", path)
|
||||||
|
}
|
||||||
|
return bookmark, err
|
||||||
|
}
|
||||||
|
|
||||||
|
buff, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return bookmark, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = toml.Decode(string(buff), &bookmark)
|
||||||
|
|
||||||
|
if bookmark.Port == 0 {
|
||||||
|
bookmark.Port = 5432
|
||||||
|
}
|
||||||
|
|
||||||
|
// List of all supported postgres modes
|
||||||
|
modes := []string{"disable", "allow", "prefer", "require", "verify-ca", "verify-full"}
|
||||||
|
valid := false
|
||||||
|
|
||||||
|
for _, mode := range modes {
|
||||||
|
if bookmark.SSLMode == mode {
|
||||||
|
valid = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to a default mode if mode is not set or invalid
|
||||||
|
// Typical typo: ssl mode set to "disabled"
|
||||||
|
if bookmark.SSLMode == "" || !valid {
|
||||||
|
bookmark.SSLMode = "disable"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default SSH port if it's not provided by user
|
||||||
|
if bookmark.SSH != nil && bookmark.SSH.Port == "" {
|
||||||
|
bookmark.SSH.Port = "22"
|
||||||
|
}
|
||||||
|
|
||||||
|
return bookmark, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileBasename(path string) string {
|
||||||
|
filename := filepath.Base(path)
|
||||||
|
return strings.Replace(filename, filepath.Ext(path), "", 1)
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package bookmarks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestManagerList(t *testing.T) {
|
||||||
|
examples := []struct {
|
||||||
|
dir string
|
||||||
|
num int
|
||||||
|
err string
|
||||||
|
}{
|
||||||
|
{"../../data", 3, ""},
|
||||||
|
{"../../data/bookmark.toml", 0, "is not a directory"},
|
||||||
|
{"../../data2", 0, ""},
|
||||||
|
{"", 0, ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ex := range examples {
|
||||||
|
t.Run(ex.dir, func(t *testing.T) {
|
||||||
|
bookmarks, err := NewManager(ex.dir).List()
|
||||||
|
if ex.err != "" {
|
||||||
|
assert.Contains(t, err.Error(), ex.err)
|
||||||
|
}
|
||||||
|
assert.Len(t, bookmarks, ex.num)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManagerListIDs(t *testing.T) {
|
||||||
|
ids, err := NewManager("../../data").ListIDs()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"bookmark", "bookmark_invalid_ssl", "bookmark_url"}, ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManagerGet(t *testing.T) {
|
||||||
|
manager := NewManager("../../data")
|
||||||
|
|
||||||
|
b, err := manager.Get("bookmark")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "bookmark", b.ID)
|
||||||
|
|
||||||
|
b, err = manager.Get("foo")
|
||||||
|
assert.Equal(t, "bookmark foo not found", err.Error())
|
||||||
|
assert.Nil(t, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_fileBasename(t *testing.T) {
|
||||||
|
assert.Equal(t, "filename", fileBasename("filename.toml"))
|
||||||
|
assert.Equal(t, "filename", fileBasename("path/filename.toml"))
|
||||||
|
assert.Equal(t, "filename", fileBasename("~/long/path/filename.toml"))
|
||||||
|
assert.Equal(t, "filename", fileBasename("filename"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_readBookmark(t *testing.T) {
|
||||||
|
t.Run("good", func(t *testing.T) {
|
||||||
|
b, err := readBookmark("../../data/bookmark.toml")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "bookmark", b.ID)
|
||||||
|
assert.Equal(t, "localhost", b.Host)
|
||||||
|
assert.Equal(t, 5432, b.Port)
|
||||||
|
assert.Equal(t, "postgres", b.User)
|
||||||
|
assert.Equal(t, "mydatabase", b.Database)
|
||||||
|
assert.Equal(t, "disable", b.SSLMode)
|
||||||
|
assert.Equal(t, "", b.Password)
|
||||||
|
assert.Equal(t, "", b.URL)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("with url", func(t *testing.T) {
|
||||||
|
b, err := readBookmark("../../data/bookmark_url.toml")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "postgres://username:password@host:port/database?sslmode=disable", b.URL)
|
||||||
|
assert.Equal(t, "", b.Host)
|
||||||
|
assert.Equal(t, 5432, b.Port)
|
||||||
|
assert.Equal(t, "", b.User)
|
||||||
|
assert.Equal(t, "", b.Database)
|
||||||
|
assert.Equal(t, "disable", b.SSLMode)
|
||||||
|
assert.Equal(t, "", b.Password)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid ssl", func(t *testing.T) {
|
||||||
|
b, err := readBookmark("../../data/bookmark_invalid_ssl.toml")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "disable", b.SSLMode)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid file", func(t *testing.T) {
|
||||||
|
_, err := readBookmark("foobar")
|
||||||
|
assert.Equal(t, "bookmark file foobar does not exist", err.Error())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid syntax", func(t *testing.T) {
|
||||||
|
_, err := readBookmark("../../data/invalid.toml")
|
||||||
|
assert.Equal(t, "toml: line 1: expected '.' or '=', but got 'e' instead", err.Error())
|
||||||
|
})
|
||||||
|
}
|
||||||
+317
@@ -0,0 +1,317 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"os/signal"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/jessevdk/go-flags"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"github.com/sosedoff/pgweb/pkg/api"
|
||||||
|
"github.com/sosedoff/pgweb/pkg/bookmarks"
|
||||||
|
"github.com/sosedoff/pgweb/pkg/client"
|
||||||
|
"github.com/sosedoff/pgweb/pkg/command"
|
||||||
|
"github.com/sosedoff/pgweb/pkg/connection"
|
||||||
|
"github.com/sosedoff/pgweb/pkg/metrics"
|
||||||
|
"github.com/sosedoff/pgweb/pkg/queries"
|
||||||
|
"github.com/sosedoff/pgweb/pkg/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
logger *logrus.Logger
|
||||||
|
options command.Options
|
||||||
|
|
||||||
|
readonlyWarning = `
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
SECURITY WARNING: You are running Pgweb in read-only mode.
|
||||||
|
This mode is designed for environments where users could potentially delete or change data.
|
||||||
|
For proper read-only access please follow PostgreSQL role management documentation.
|
||||||
|
--------------------------------------------------------------------------------`
|
||||||
|
|
||||||
|
regexErrConnectionRefused = regexp.MustCompile(`(connection|actively) refused`)
|
||||||
|
regexErrAuthFailed = regexp.MustCompile(`authentication failed`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
logger = logrus.New()
|
||||||
|
}
|
||||||
|
|
||||||
|
func exitWithMessage(message string) {
|
||||||
|
fmt.Println("Error:", message)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func initClientUsingBookmark(baseDir, bookmarkName string) (*client.Client, error) {
|
||||||
|
manager := bookmarks.NewManager(baseDir)
|
||||||
|
bookmark, err := manager.Get(bookmarkName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.NewFromBookmark(bookmark)
|
||||||
|
}
|
||||||
|
|
||||||
|
func initClient() {
|
||||||
|
if connection.IsBlank(command.Opts) && options.Bookmark == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var cl *client.Client
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if options.Bookmark != "" {
|
||||||
|
cl, err = initClientUsingBookmark(options.BookmarksDir, options.Bookmark)
|
||||||
|
} else {
|
||||||
|
cl, err = client.New()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
exitWithMessage(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if command.Opts.Debug {
|
||||||
|
fmt.Println("Server connection string:", cl.ConnectionString)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Connecting to server...")
|
||||||
|
if err := cl.Test(); err != nil {
|
||||||
|
msg := err.Error()
|
||||||
|
|
||||||
|
// Check if we're trying to connect to the default database.
|
||||||
|
if command.Opts.DbName == "" && command.Opts.URL == "" {
|
||||||
|
// If database does not exist, allow user to connect from the UI.
|
||||||
|
if strings.Contains(msg, "database") && strings.Contains(msg, "does not exist") {
|
||||||
|
fmt.Println("Error:", msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do not bail if local server is not running.
|
||||||
|
if regexErrConnectionRefused.MatchString(msg) {
|
||||||
|
fmt.Println("Error:", msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do not bail if local auth is invalid
|
||||||
|
if regexErrAuthFailed.MatchString(msg) {
|
||||||
|
fmt.Println("Error:", msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exitWithMessage(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !command.Opts.Sessions {
|
||||||
|
fmt.Printf("Connected to %s\n", cl.ServerVersionInfo())
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Checking database objects...")
|
||||||
|
_, err = cl.Objects()
|
||||||
|
if err != nil {
|
||||||
|
exitWithMessage(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
api.DbClient = cl
|
||||||
|
}
|
||||||
|
|
||||||
|
func initOptions() {
|
||||||
|
opts, err := command.ParseOptions(os.Args)
|
||||||
|
if err != nil {
|
||||||
|
switch errVal := err.(type) {
|
||||||
|
case *flags.Error:
|
||||||
|
if errVal.Type == flags.ErrHelp {
|
||||||
|
fmt.Println("Available environment variables:")
|
||||||
|
fmt.Println(command.AvailableEnvVars())
|
||||||
|
}
|
||||||
|
// no need to print error, flags package already does that
|
||||||
|
default:
|
||||||
|
fmt.Println(err.Error())
|
||||||
|
}
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
command.Opts = opts
|
||||||
|
options = opts
|
||||||
|
|
||||||
|
if options.Version {
|
||||||
|
printVersion()
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := configureLogger(opts); err != nil {
|
||||||
|
exitWithMessage(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if options.ReadOnly {
|
||||||
|
fmt.Println(readonlyWarning)
|
||||||
|
}
|
||||||
|
|
||||||
|
if options.BinaryCodec != "" {
|
||||||
|
if err := client.SetBinaryCodec(options.BinaryCodec); err != nil {
|
||||||
|
exitWithMessage(err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
configureLocalQueryStore()
|
||||||
|
printVersion()
|
||||||
|
}
|
||||||
|
|
||||||
|
func configureLocalQueryStore() {
|
||||||
|
if options.Sessions || options.QueriesDir == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stat, err := os.Stat(options.QueriesDir)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
logger.Debugf("local queries directory %q does not exist, disabling feature", options.QueriesDir)
|
||||||
|
} else {
|
||||||
|
logger.Debugf("local queries feature disabled due to error: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !stat.IsDir() {
|
||||||
|
logger.Debugf("local queries path %q is not a directory", options.QueriesDir)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
api.QueryStore = queries.NewStore(options.QueriesDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func configureLogger(opts command.Options) error {
|
||||||
|
if options.Debug {
|
||||||
|
logger.SetLevel(logrus.DebugLevel)
|
||||||
|
} else {
|
||||||
|
lvl, err := logrus.ParseLevel(opts.LogLevel)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
logger.SetLevel(lvl)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch options.LogFormat {
|
||||||
|
case "text":
|
||||||
|
logger.SetFormatter(&logrus.TextFormatter{})
|
||||||
|
case "json":
|
||||||
|
logger.SetFormatter(&logrus.JSONFormatter{})
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid logger format: %v", options.LogFormat)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func printVersion() {
|
||||||
|
fmt.Println(command.VersionString())
|
||||||
|
}
|
||||||
|
|
||||||
|
func startServer() {
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(api.RequestLogger(logger))
|
||||||
|
router.Use(gin.Recovery())
|
||||||
|
|
||||||
|
// Enable HTTP basic authentication only if both user and password are set
|
||||||
|
if options.AuthUser != "" && options.AuthPass != "" {
|
||||||
|
auth := map[string]string{options.AuthUser: options.AuthPass}
|
||||||
|
router.Use(gin.BasicAuth(auth))
|
||||||
|
}
|
||||||
|
|
||||||
|
api.SetLogger(logger)
|
||||||
|
api.SetupRoutes(router)
|
||||||
|
api.SetupMetrics(router)
|
||||||
|
|
||||||
|
fmt.Println("Starting server...")
|
||||||
|
go func() {
|
||||||
|
metrics.SetHealty(true)
|
||||||
|
|
||||||
|
err := router.Run(fmt.Sprintf("%v:%v", options.HTTPHost, options.HTTPPort))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Cant start server:", err)
|
||||||
|
if strings.Contains(err.Error(), "address already in use") {
|
||||||
|
openPage()
|
||||||
|
}
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func startMetricsServer() {
|
||||||
|
serverAddr := fmt.Sprintf("%v:%v", command.Opts.HTTPHost, command.Opts.HTTPPort)
|
||||||
|
if options.MetricsAddr == serverAddr {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := metrics.StartServer(logger, options.MetricsPath, options.MetricsAddr)
|
||||||
|
if err != nil {
|
||||||
|
logger.WithError(err).Fatal("unable to start prometheus metrics server")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSignals() {
|
||||||
|
c := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
||||||
|
<-c
|
||||||
|
}
|
||||||
|
|
||||||
|
func openPage() {
|
||||||
|
url := fmt.Sprintf("http://%v:%v/%s", options.HTTPHost, options.HTTPPort, options.Prefix)
|
||||||
|
fmt.Println("To view database open", url, "in browser")
|
||||||
|
|
||||||
|
if options.SkipOpen {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := exec.Command("which", "open").Output()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = exec.Command("open", url).Output()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Unable to auto-open pgweb URL:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Run() {
|
||||||
|
initOptions()
|
||||||
|
initClient()
|
||||||
|
|
||||||
|
if api.DbClient != nil {
|
||||||
|
defer api.DbClient.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if !options.Debug {
|
||||||
|
gin.SetMode("release")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print memory usage every 30 seconds with debug flag
|
||||||
|
if options.Debug {
|
||||||
|
util.StartProfiler()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start session cleanup worker
|
||||||
|
if options.Sessions {
|
||||||
|
api.DbSessions = api.NewSessionManager(logger)
|
||||||
|
|
||||||
|
if !command.Opts.DisableConnectionIdleTimeout {
|
||||||
|
api.DbSessions.SetIdleTimeout(time.Minute * time.Duration(command.Opts.ConnectionIdleTimeout))
|
||||||
|
go api.DbSessions.RunPeriodicCleanup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if options.MetricsEnabled && options.MetricsAddr != "" {
|
||||||
|
go startMetricsServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
startServer()
|
||||||
|
openPage()
|
||||||
|
handleSignals()
|
||||||
|
}
|
||||||
@@ -0,0 +1,628 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
neturl "net/url"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
|
||||||
|
"github.com/sosedoff/pgweb/pkg/bookmarks"
|
||||||
|
"github.com/sosedoff/pgweb/pkg/command"
|
||||||
|
"github.com/sosedoff/pgweb/pkg/connection"
|
||||||
|
"github.com/sosedoff/pgweb/pkg/history"
|
||||||
|
"github.com/sosedoff/pgweb/pkg/shared"
|
||||||
|
"github.com/sosedoff/pgweb/pkg/statements"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
db *sqlx.DB
|
||||||
|
tunnel *Tunnel
|
||||||
|
serverVersion string
|
||||||
|
serverType string
|
||||||
|
lastQueryTime time.Time
|
||||||
|
queryTimeout time.Duration
|
||||||
|
closed bool
|
||||||
|
External bool `json:"external"`
|
||||||
|
History []history.Record `json:"history"`
|
||||||
|
ConnectionString string `json:"connection_string"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSchemaAndTable(str string) (string, string) {
|
||||||
|
chunks := strings.Split(str, ".")
|
||||||
|
if len(chunks) == 1 {
|
||||||
|
return "public", chunks[0]
|
||||||
|
}
|
||||||
|
return chunks[0], chunks[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() (*Client, error) {
|
||||||
|
str, err := connection.BuildStringFromOptions(command.Opts)
|
||||||
|
|
||||||
|
if command.Opts.Debug && str != "" {
|
||||||
|
fmt.Println("Creating a new client for:", str)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := sqlx.Open("postgres", str)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
client := Client{
|
||||||
|
db: db,
|
||||||
|
ConnectionString: str,
|
||||||
|
History: history.New(),
|
||||||
|
}
|
||||||
|
|
||||||
|
client.init()
|
||||||
|
return &client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFromUrl(url string, sshInfo *shared.SSHInfo) (*Client, error) {
|
||||||
|
var tunnel *Tunnel
|
||||||
|
|
||||||
|
if sshInfo != nil {
|
||||||
|
if command.Opts.DisableSSH {
|
||||||
|
return nil, fmt.Errorf("ssh connections are disabled")
|
||||||
|
}
|
||||||
|
if command.Opts.Debug {
|
||||||
|
fmt.Println("Opening SSH tunnel for:", sshInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
tunnel, err := NewTunnel(sshInfo, url)
|
||||||
|
if err != nil {
|
||||||
|
tunnel.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tunnel.Configure()
|
||||||
|
if err != nil {
|
||||||
|
tunnel.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
go tunnel.Start()
|
||||||
|
|
||||||
|
uri, err := neturl.Parse(url)
|
||||||
|
if err != nil {
|
||||||
|
tunnel.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override remote postgres port with local proxy port
|
||||||
|
url = strings.Replace(url, uri.Host, fmt.Sprintf("127.0.0.1:%v", tunnel.Port), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if command.Opts.Debug {
|
||||||
|
fmt.Println("Creating a new client for:", url)
|
||||||
|
}
|
||||||
|
|
||||||
|
uri, err := neturl.Parse(url)
|
||||||
|
if err == nil && uri.Path == "" {
|
||||||
|
return nil, fmt.Errorf("Database name is not provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := sqlx.Open("postgres", url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
client := Client{
|
||||||
|
db: db,
|
||||||
|
tunnel: tunnel,
|
||||||
|
serverType: postgresType,
|
||||||
|
ConnectionString: url,
|
||||||
|
History: history.New(),
|
||||||
|
}
|
||||||
|
|
||||||
|
client.init()
|
||||||
|
return &client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFromBookmark(bookmark *bookmarks.Bookmark) (*Client, error) {
|
||||||
|
var (
|
||||||
|
connStr string
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
options := bookmark.ConvertToOptions()
|
||||||
|
if options.URL != "" {
|
||||||
|
connStr = options.URL
|
||||||
|
} else {
|
||||||
|
connStr, err = connection.BuildStringFromOptions(options)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sshInfo *shared.SSHInfo
|
||||||
|
if !bookmark.SSHInfoIsEmpty() {
|
||||||
|
sshInfo = bookmark.SSH
|
||||||
|
}
|
||||||
|
|
||||||
|
return NewFromUrl(connStr, sshInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) init() {
|
||||||
|
if command.Opts.QueryTimeout > 0 {
|
||||||
|
client.queryTimeout = time.Second * time.Duration(command.Opts.QueryTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
client.setServerVersion()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) setServerVersion() {
|
||||||
|
res, err := client.query("SELECT version()")
|
||||||
|
if err != nil || len(res.Rows) < 1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
version := res.Rows[0][0].(string)
|
||||||
|
match, serverType, serverVersion := detectServerTypeAndVersion(version)
|
||||||
|
if match {
|
||||||
|
client.serverType = serverType
|
||||||
|
client.serverVersion = serverVersion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) Test() error {
|
||||||
|
return client.db.Ping()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) TestWithTimeout(timeout time.Duration) (result error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Check connection status right away without waiting for the ticker to kick in.
|
||||||
|
// We're expecting to get "connection refused" here for the most part.
|
||||||
|
if err := client.db.PingContext(ctx); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.NewTicker(250 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
result = client.db.PingContext(ctx)
|
||||||
|
if result == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) Info() (*Result, error) {
|
||||||
|
result, err := client.query(statements.Info)
|
||||||
|
if err != nil {
|
||||||
|
msg := err.Error()
|
||||||
|
if strings.Contains(msg, "inet_") && (strings.Contains(msg, "not supported") || strings.Contains(msg, "permission denied")) {
|
||||||
|
// Fetch client information without inet_ function calls
|
||||||
|
result, err = client.query(statements.InfoSimple)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) Databases() ([]string, error) {
|
||||||
|
return client.fetchRows(statements.Databases)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) Schemas() ([]string, error) {
|
||||||
|
return client.fetchRows(statements.Schemas)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) Objects() (*Result, error) {
|
||||||
|
return client.query(statements.Objects)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) Table(table string) (*Result, error) {
|
||||||
|
schema, table := getSchemaAndTable(table)
|
||||||
|
return client.query(statements.TableSchema, schema, table)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) MaterializedView(name string) (*Result, error) {
|
||||||
|
return client.query(statements.MaterializedView, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) Function(id string) (*Result, error) {
|
||||||
|
return client.query(statements.Function, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) TableRows(table string, opts RowsOptions) (*Result, error) {
|
||||||
|
schema, table := getSchemaAndTable(table)
|
||||||
|
sql := fmt.Sprintf(`SELECT * FROM "%s"."%s"`, schema, table)
|
||||||
|
|
||||||
|
if opts.Where != "" {
|
||||||
|
sql += fmt.Sprintf(" WHERE %s", opts.Where)
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.SortColumn != "" {
|
||||||
|
if opts.SortOrder == "" {
|
||||||
|
opts.SortOrder = "ASC"
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += fmt.Sprintf(` ORDER BY "%s" %s`, opts.SortColumn, opts.SortOrder)
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.Limit > 0 {
|
||||||
|
sql += fmt.Sprintf(" LIMIT %d", opts.Limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.Offset > 0 {
|
||||||
|
sql += fmt.Sprintf(" OFFSET %d", opts.Offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.query(sql)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) EstimatedTableRowsCount(table string, opts RowsOptions) (*Result, error) {
|
||||||
|
schema, table := getSchemaAndTable(table)
|
||||||
|
result, err := client.query(statements.EstimatedTableRowCount, schema, table)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// float64 to int64 conversion
|
||||||
|
estimatedRowsCount := result.Rows[0][0].(float64)
|
||||||
|
result.Rows[0] = Row{int64(estimatedRowsCount)}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) TableRowsCount(table string, opts RowsOptions) (*Result, error) {
|
||||||
|
// Return postgres estimated rows count on empty filter
|
||||||
|
if opts.Where == "" && client.serverType == postgresType {
|
||||||
|
res, err := client.EstimatedTableRowsCount(table, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
n := res.Rows[0][0].(int64)
|
||||||
|
if n >= 100000 {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
schema, tableName := getSchemaAndTable(table)
|
||||||
|
sql := fmt.Sprintf(`SELECT COUNT(1) FROM "%s"."%s"`, schema, tableName)
|
||||||
|
|
||||||
|
if opts.Where != "" {
|
||||||
|
sql += fmt.Sprintf(" WHERE %s", opts.Where)
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.query(sql)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) TableInfo(table string) (*Result, error) {
|
||||||
|
if client.serverType == cockroachType {
|
||||||
|
return client.query(statements.TableInfoCockroach)
|
||||||
|
}
|
||||||
|
schema, table := getSchemaAndTable(table)
|
||||||
|
return client.query(statements.TableInfo, fmt.Sprintf(`"%s"."%s"`, schema, table))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) TableIndexes(table string) (*Result, error) {
|
||||||
|
schema, table := getSchemaAndTable(table)
|
||||||
|
res, err := client.query(statements.TableIndexes, schema, table)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) TableConstraints(table string) (*Result, error) {
|
||||||
|
schema, table := getSchemaAndTable(table)
|
||||||
|
res, err := client.query(statements.TableConstraints, schema, table)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) TablesStats() (*Result, error) {
|
||||||
|
return client.query(statements.TablesStats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns all active queriers on the server
|
||||||
|
func (client *Client) Activity() (*Result, error) {
|
||||||
|
if client.serverType == cockroachType {
|
||||||
|
return client.query("SHOW QUERIES")
|
||||||
|
}
|
||||||
|
|
||||||
|
version := getMajorMinorVersionString(client.serverVersion)
|
||||||
|
query := statements.Activity[version]
|
||||||
|
if query == "" {
|
||||||
|
query = statements.Activity["default"]
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.query(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) Query(query string) (*Result, error) {
|
||||||
|
res, err := client.query(query)
|
||||||
|
|
||||||
|
// Save history records only if query did not fail
|
||||||
|
if err == nil && !client.hasHistoryRecord(query) {
|
||||||
|
client.History = append(client.History, history.NewRecord(query))
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) SetReadOnlyMode() error {
|
||||||
|
var value string
|
||||||
|
if err := client.db.Get(&value, "SHOW default_transaction_read_only;"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if value == "off" {
|
||||||
|
_, err := client.db.Exec("SET default_transaction_read_only=on;")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) ServerVersionInfo() string {
|
||||||
|
return fmt.Sprintf("%s %s", client.serverType, client.serverVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) ServerVersion() string {
|
||||||
|
return client.serverVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) context() (context.Context, context.CancelFunc) {
|
||||||
|
if client.queryTimeout > 0 {
|
||||||
|
return context.WithTimeout(context.Background(), client.queryTimeout)
|
||||||
|
}
|
||||||
|
return context.Background(), func() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) exec(query string, args ...interface{}) (*Result, error) {
|
||||||
|
ctx, cancel := client.context()
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
queryStart := time.Now()
|
||||||
|
res, err := client.db.ExecContext(ctx, query, args...)
|
||||||
|
queryFinish := time.Now()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
affected, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := Result{
|
||||||
|
Columns: []string{"Rows Affected"},
|
||||||
|
Rows: []Row{
|
||||||
|
{affected},
|
||||||
|
},
|
||||||
|
Stats: &ResultStats{
|
||||||
|
ColumnsCount: 1,
|
||||||
|
RowsCount: 1,
|
||||||
|
QueryStartTime: queryStart.UTC(),
|
||||||
|
QueryFinishTime: queryFinish.UTC(),
|
||||||
|
QueryDuration: queryFinish.Sub(queryStart).Milliseconds(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) query(query string, args ...interface{}) (*Result, error) {
|
||||||
|
if client.db == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the last usage time
|
||||||
|
defer func() {
|
||||||
|
client.lastQueryTime = time.Now().UTC()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// We're going to force-set transaction mode on every query.
|
||||||
|
// This is needed so that default mode could not be changed by user.
|
||||||
|
if command.Opts.ReadOnly {
|
||||||
|
if err := client.SetReadOnlyMode(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if containsRestrictedKeywords(query) {
|
||||||
|
return nil, errors.New("query contains keywords not allowed in read-only mode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
action := strings.ToLower(strings.Split(query, " ")[0])
|
||||||
|
hasReturnValues := strings.Contains(strings.ToLower(query), " returning ")
|
||||||
|
|
||||||
|
if (action == "update" || action == "delete") && !hasReturnValues {
|
||||||
|
return client.exec(query, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := client.context()
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
queryStart := time.Now()
|
||||||
|
rows, err := client.db.QueryxContext(ctx, query, args...)
|
||||||
|
queryFinish := time.Now()
|
||||||
|
if err != nil {
|
||||||
|
if command.Opts.Debug {
|
||||||
|
log.Println("Failed query:", query, "\nArgs:", args)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
cols, err := rows.Columns()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure to never return null columns
|
||||||
|
if cols == nil {
|
||||||
|
cols = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := Result{
|
||||||
|
Columns: cols,
|
||||||
|
Rows: []Row{},
|
||||||
|
}
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
obj, err := rows.SliceScan()
|
||||||
|
|
||||||
|
for i, item := range obj {
|
||||||
|
if item == nil {
|
||||||
|
obj[i] = nil
|
||||||
|
} else {
|
||||||
|
t := reflect.TypeOf(item).Kind().String()
|
||||||
|
|
||||||
|
if t == "slice" {
|
||||||
|
obj[i] = string(item.([]byte))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
result.Rows = append(result.Rows, obj)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Stats = &ResultStats{
|
||||||
|
ColumnsCount: len(cols),
|
||||||
|
RowsCount: len(result.Rows),
|
||||||
|
QueryStartTime: queryStart.UTC(),
|
||||||
|
QueryFinishTime: queryFinish.UTC(),
|
||||||
|
QueryDuration: queryFinish.Sub(queryStart).Milliseconds(),
|
||||||
|
}
|
||||||
|
|
||||||
|
result.PostProcess()
|
||||||
|
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close database connection
|
||||||
|
func (client *Client) Close() error {
|
||||||
|
if client.closed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
client.closed = true
|
||||||
|
}()
|
||||||
|
|
||||||
|
if client.tunnel != nil {
|
||||||
|
client.tunnel.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if client.db != nil {
|
||||||
|
return client.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) IsClosed() bool {
|
||||||
|
return c.closed
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) LastQueryTime() time.Time {
|
||||||
|
return c.lastQueryTime
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) IsIdle() bool {
|
||||||
|
mins := int(time.Since(client.lastQueryTime).Minutes())
|
||||||
|
|
||||||
|
if command.Opts.ConnectionIdleTimeout > 0 {
|
||||||
|
return mins >= command.Opts.ConnectionIdleTimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all rows as strings for a single column
|
||||||
|
func (client *Client) fetchRows(q string) ([]string, error) {
|
||||||
|
res, err := client.query(q)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init empty slice so json.Marshal will encode it to "[]" instead of "null"
|
||||||
|
results := make([]string, 0)
|
||||||
|
|
||||||
|
for _, row := range res.Rows {
|
||||||
|
results = append(results, row[0].(string))
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) hasHistoryRecord(query string) bool {
|
||||||
|
result := false
|
||||||
|
|
||||||
|
for _, record := range client.History {
|
||||||
|
if record.Query == query {
|
||||||
|
result = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConnContext struct {
|
||||||
|
Host string
|
||||||
|
User string
|
||||||
|
Database string
|
||||||
|
Mode string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c ConnContext) String() string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"host=%q user=%q database=%q mode=%q",
|
||||||
|
c.Host, c.User, c.Database, c.Mode,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnContext returns information about current database connection
|
||||||
|
func (client *Client) GetConnContext() (*ConnContext, error) {
|
||||||
|
url, err := neturl.Parse(client.ConnectionString)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
connCtx := ConnContext{
|
||||||
|
Host: url.Hostname(),
|
||||||
|
Mode: "default",
|
||||||
|
}
|
||||||
|
|
||||||
|
if command.Opts.ReadOnly {
|
||||||
|
connCtx.Mode = "readonly"
|
||||||
|
}
|
||||||
|
|
||||||
|
row := client.db.QueryRowContext(ctx, "SELECT current_user, current_database()")
|
||||||
|
if err := row.Scan(&connCtx.User, &connCtx.Database); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &connCtx, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,714 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sosedoff/pgweb/pkg/command"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
testClient *Client
|
||||||
|
testCommands map[string]string
|
||||||
|
serverHost string
|
||||||
|
serverPort string
|
||||||
|
serverUser string
|
||||||
|
serverPassword string
|
||||||
|
serverDatabase string
|
||||||
|
)
|
||||||
|
|
||||||
|
func mapKeys(data map[string]*Objects) []string {
|
||||||
|
result := []string{}
|
||||||
|
for k := range data {
|
||||||
|
result = append(result, k)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func objectNames(data []Object) []string {
|
||||||
|
names := make([]string, len(data))
|
||||||
|
for i, obj := range data {
|
||||||
|
names[i] = obj.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(names)
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertMatches is a helper method to check if src slice contains any elements of expected slice
|
||||||
|
func assertMatches(t *testing.T, expected, src []string) {
|
||||||
|
assert.NotEqual(t, 0, len(expected))
|
||||||
|
assert.NotEqual(t, 0, len(src))
|
||||||
|
|
||||||
|
for _, val := range expected {
|
||||||
|
assert.Contains(t, src, val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pgVersion() (int, int) {
|
||||||
|
var major, minor int
|
||||||
|
fmt.Sscanf(os.Getenv("PGVERSION"), "%d.%d", &major, &minor)
|
||||||
|
return major, minor
|
||||||
|
}
|
||||||
|
|
||||||
|
func getVar(name, def string) string {
|
||||||
|
val := os.Getenv(name)
|
||||||
|
if val == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
func initVars() {
|
||||||
|
// We need to load default options to make sure all stuff works
|
||||||
|
if err := command.SetDefaultOptions(); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
serverHost = getVar("PGHOST", "localhost")
|
||||||
|
serverPort = getVar("PGPORT", "5432")
|
||||||
|
serverUser = getVar("PGUSER", "postgres")
|
||||||
|
serverPassword = getVar("PGPASSWORD", "postgres")
|
||||||
|
serverDatabase = getVar("PGDATABASE", "booktown")
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupCommands() {
|
||||||
|
testCommands = map[string]string{
|
||||||
|
"createdb": "createdb",
|
||||||
|
"psql": "psql",
|
||||||
|
"dropdb": "dropdb",
|
||||||
|
}
|
||||||
|
|
||||||
|
if onWindows() {
|
||||||
|
for k, v := range testCommands {
|
||||||
|
testCommands[k] = v + ".exe"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func onWindows() bool {
|
||||||
|
return runtime.GOOS == "windows"
|
||||||
|
}
|
||||||
|
|
||||||
|
func setup() {
|
||||||
|
// No pretty JSON for tests
|
||||||
|
command.Opts.DisablePrettyJSON = true
|
||||||
|
|
||||||
|
out, err := exec.Command(
|
||||||
|
testCommands["createdb"],
|
||||||
|
"-U", serverUser,
|
||||||
|
"-h", serverHost,
|
||||||
|
"-p", serverPort,
|
||||||
|
serverDatabase,
|
||||||
|
).CombinedOutput()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Database creation failed:", string(out))
|
||||||
|
fmt.Println("Error:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err = exec.Command(
|
||||||
|
testCommands["psql"],
|
||||||
|
"-U", serverUser,
|
||||||
|
"-h", serverHost,
|
||||||
|
"-p", serverPort,
|
||||||
|
"-f", "../../data/booktown.sql",
|
||||||
|
serverDatabase,
|
||||||
|
).CombinedOutput()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Database import failed:", string(out))
|
||||||
|
fmt.Println("Error:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupClient() {
|
||||||
|
url := fmt.Sprintf("postgres://%s@%s:%s/%s?sslmode=disable", serverUser, serverHost, serverPort, serverDatabase)
|
||||||
|
testClient, _ = NewFromUrl(url, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func teardownClient() {
|
||||||
|
if testClient != nil {
|
||||||
|
testClient.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func teardown(t *testing.T, allowFail bool) {
|
||||||
|
output, err := exec.Command(
|
||||||
|
testCommands["dropdb"],
|
||||||
|
"-U", serverUser,
|
||||||
|
"-h", serverHost,
|
||||||
|
"-p", serverPort,
|
||||||
|
serverDatabase,
|
||||||
|
).CombinedOutput()
|
||||||
|
|
||||||
|
if err != nil && strings.Contains(err.Error(), "does not exist") {
|
||||||
|
t.Log("Teardown error:", err)
|
||||||
|
t.Logf("%s\n", output)
|
||||||
|
|
||||||
|
if !allowFail {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNewClientFromURL(t *testing.T) {
|
||||||
|
t.Run("postgres prefix", func(t *testing.T) {
|
||||||
|
url := fmt.Sprintf("postgres://%s@%s:%s/%s?sslmode=disable", serverUser, serverHost, serverPort, serverDatabase)
|
||||||
|
client, err := NewFromUrl(url, nil)
|
||||||
|
|
||||||
|
assert.Equal(t, nil, err)
|
||||||
|
assert.Equal(t, url, client.ConnectionString)
|
||||||
|
assert.NoError(t, client.Close())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("postgresql prefix", func(t *testing.T) {
|
||||||
|
url := fmt.Sprintf("postgresql://%s@%s:%s/%s?sslmode=disable", serverUser, serverHost, serverPort, serverDatabase)
|
||||||
|
client, err := NewFromUrl(url, nil)
|
||||||
|
|
||||||
|
assert.Equal(t, nil, err)
|
||||||
|
assert.Equal(t, url, client.ConnectionString)
|
||||||
|
assert.NoError(t, client.Close())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func testClientIdleTime(t *testing.T) {
|
||||||
|
examples := map[time.Time]bool{
|
||||||
|
time.Now(): false, // Current time
|
||||||
|
time.Now().Add(time.Minute * -30): false, // 30 minutes ago
|
||||||
|
time.Now().Add(time.Minute * -240): true, // 240 minutes ago
|
||||||
|
time.Now().Add(time.Minute * 30): false, // 30 minutes in future
|
||||||
|
time.Now().Add(time.Minute * 128): false, // 128 minutes in future
|
||||||
|
}
|
||||||
|
|
||||||
|
for ts, expected := range examples {
|
||||||
|
testClient.lastQueryTime = ts
|
||||||
|
assert.Equal(t, expected, testClient.IsIdle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTest(t *testing.T) {
|
||||||
|
assert.NoError(t, testClient.Test())
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInfo(t *testing.T) {
|
||||||
|
t.Run("normal", func(t *testing.T) {
|
||||||
|
expected := []string{
|
||||||
|
"session_user",
|
||||||
|
"current_user",
|
||||||
|
"current_database",
|
||||||
|
"current_schemas",
|
||||||
|
"inet_client_addr",
|
||||||
|
"inet_client_port",
|
||||||
|
"inet_server_addr",
|
||||||
|
"inet_server_port",
|
||||||
|
"version",
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := testClient.Info()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, expected, res.Columns)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("with restrictions", func(t *testing.T) {
|
||||||
|
expected := []string{
|
||||||
|
"session_user",
|
||||||
|
"current_user",
|
||||||
|
"current_database",
|
||||||
|
"current_schemas",
|
||||||
|
"version",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare a new user and database
|
||||||
|
testClient.db.MustExec("DROP DATABASE IF EXISTS testdb")
|
||||||
|
testClient.db.Exec("DROP OWNED BY IF EXISTS testuser") //nolint:all
|
||||||
|
testClient.db.MustExec("DROP ROLE IF EXISTS testuser")
|
||||||
|
testClient.db.MustExec("CREATE ROLE testuser WITH PASSWORD 'secret' LOGIN NOSUPERUSER NOINHERIT")
|
||||||
|
testClient.db.MustExec("CREATE DATABASE testdb OWNER testuser")
|
||||||
|
|
||||||
|
// Disable access to inet_ calls for new user
|
||||||
|
url := fmt.Sprintf("postgres://%s:@%s:%s/testdb?sslmode=disable", serverUser, serverHost, serverPort)
|
||||||
|
client, err := NewFromUrl(url, nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
client.db.MustExec("REVOKE EXECUTE ON FUNCTION inet_client_addr() FROM PUBLIC")
|
||||||
|
assert.NoError(t, client.Close())
|
||||||
|
|
||||||
|
// Connect using new user
|
||||||
|
url = fmt.Sprintf("postgres://testuser:secret@%s:%s/testdb?sslmode=disable", serverHost, serverPort)
|
||||||
|
client, err = NewFromUrl(url, nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
res, err := client.Info()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, expected, res.Columns)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func testActivity(t *testing.T) {
|
||||||
|
expected := []string{"datid", "pid", "query", "query_start", "state", "client_addr"}
|
||||||
|
|
||||||
|
res, err := testClient.Activity()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assertMatches(t, expected, res.Columns)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDatabases(t *testing.T) {
|
||||||
|
res, err := testClient.Databases()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assertMatches(t, []string{"booktown", "postgres"}, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSchemas(t *testing.T) {
|
||||||
|
res, err := testClient.Schemas()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"public"}, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testObjects(t *testing.T) {
|
||||||
|
res, err := testClient.Objects()
|
||||||
|
objects := ObjectsFromResult(res)
|
||||||
|
|
||||||
|
tables := []string{
|
||||||
|
"alternate_stock",
|
||||||
|
"authors",
|
||||||
|
"book_backup",
|
||||||
|
"book_queue",
|
||||||
|
"books",
|
||||||
|
"customers",
|
||||||
|
"daily_inventory",
|
||||||
|
"distinguished_authors",
|
||||||
|
"dummies",
|
||||||
|
"editions",
|
||||||
|
"employees",
|
||||||
|
"favorite_authors",
|
||||||
|
"favorite_books",
|
||||||
|
"money_example",
|
||||||
|
"my_list",
|
||||||
|
"numeric_values",
|
||||||
|
"publishers",
|
||||||
|
"schedules",
|
||||||
|
"shipments",
|
||||||
|
"states",
|
||||||
|
"stock",
|
||||||
|
"stock_backup",
|
||||||
|
"subjects",
|
||||||
|
"text_sorting",
|
||||||
|
}
|
||||||
|
|
||||||
|
functions := []string{
|
||||||
|
"add_shipment",
|
||||||
|
"add_two_loop",
|
||||||
|
"books_by_subject",
|
||||||
|
"compound_word",
|
||||||
|
"count_by_two",
|
||||||
|
"double_price",
|
||||||
|
"extract_all_titles",
|
||||||
|
"extract_all_titles2",
|
||||||
|
"extract_title",
|
||||||
|
"first",
|
||||||
|
"get_author",
|
||||||
|
"get_author",
|
||||||
|
"get_customer_id",
|
||||||
|
"get_customer_name",
|
||||||
|
"html_linebreaks",
|
||||||
|
"in_stock",
|
||||||
|
"isbn_to_title",
|
||||||
|
"mixed",
|
||||||
|
"raise_test",
|
||||||
|
"ship_item",
|
||||||
|
"stock_amount",
|
||||||
|
"test",
|
||||||
|
"title",
|
||||||
|
"triple_price",
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"oid", "schema", "name", "type", "owner", "comment"}, res.Columns)
|
||||||
|
assert.Equal(t, []string{"public"}, mapKeys(objects))
|
||||||
|
assert.Equal(t, tables, objectNames(objects["public"].Tables))
|
||||||
|
assertMatches(t, functions, objectNames(objects["public"].Functions))
|
||||||
|
assert.Equal(t, []string{"recent_shipments", "stock_view"}, objectNames(objects["public"].Views))
|
||||||
|
assert.Equal(t, []string{"author_ids", "book_ids", "shipments_ship_id_seq", "subject_ids"}, objectNames(objects["public"].Sequences))
|
||||||
|
|
||||||
|
major, minor := pgVersion()
|
||||||
|
if minor == 0 || minor >= 3 {
|
||||||
|
assert.Equal(t, []string{"m_stock_view"}, objectNames(objects["public"].MaterializedViews))
|
||||||
|
} else {
|
||||||
|
t.Logf("Skipping materialized view on %d.%d\n", major, minor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTable(t *testing.T) {
|
||||||
|
columns := []string{
|
||||||
|
"column_name",
|
||||||
|
"data_type",
|
||||||
|
"is_nullable",
|
||||||
|
"character_maximum_length",
|
||||||
|
"character_set_catalog",
|
||||||
|
"column_default",
|
||||||
|
"comment",
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := testClient.Table("books")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, columns, res.Columns)
|
||||||
|
assert.Equal(t, 4, len(res.Rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTableRows(t *testing.T) {
|
||||||
|
res, err := testClient.TableRows("books", RowsOptions{})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 4, len(res.Columns))
|
||||||
|
assert.Equal(t, 15, len(res.Rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTableInfo(t *testing.T) {
|
||||||
|
res, err := testClient.TableInfo("books")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 4, len(res.Columns))
|
||||||
|
assert.Equal(t, 1, len(res.Rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEstimatedTableRowsCount(t *testing.T) {
|
||||||
|
res, err := testClient.EstimatedTableRowsCount("books", RowsOptions{})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"reltuples"}, res.Columns)
|
||||||
|
assert.Equal(t, []Row{{int64(15)}}, res.Rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTableRowsCount(t *testing.T) {
|
||||||
|
res, err := testClient.TableRowsCount("books", RowsOptions{})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"count"}, res.Columns)
|
||||||
|
assert.Equal(t, []Row{{int64(15)}}, res.Rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTableRowsCountWithLargeTable(t *testing.T) {
|
||||||
|
testClient.db.MustExec(`CREATE TABLE large_table AS SELECT s FROM generate_series(1,1000000) s;`)
|
||||||
|
testClient.db.MustExec(`VACUUM large_table;`)
|
||||||
|
|
||||||
|
res, err := testClient.TableRowsCount("large_table", RowsOptions{})
|
||||||
|
assert.Equal(t, nil, err)
|
||||||
|
assert.Equal(t, []string{"reltuples"}, res.Columns)
|
||||||
|
assert.Equal(t, []Row{{int64(1000000)}}, res.Rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTableIndexes(t *testing.T) {
|
||||||
|
res, err := testClient.TableIndexes("books")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"index_name", "index_size", "index_definition"}, res.Columns)
|
||||||
|
assert.Equal(t, 2, len(res.Rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTableConstraints(t *testing.T) {
|
||||||
|
res, err := testClient.TableConstraints("editions")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"name", "definition"}, res.Columns)
|
||||||
|
assert.Equal(t, Row{"pkey", "PRIMARY KEY (isbn)"}, res.Rows[0])
|
||||||
|
assert.Equal(t, Row{"integrity", "CHECK (book_id IS NOT NULL AND edition IS NOT NULL)"}, res.Rows[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTableNameWithCamelCase(t *testing.T) {
|
||||||
|
testClient.db.MustExec(`CREATE TABLE "exampleTable" (id int, name varchar);`)
|
||||||
|
testClient.db.MustExec(`INSERT INTO "exampleTable" (id, name) VALUES (1, 'foo'), (2, 'bar');`)
|
||||||
|
|
||||||
|
_, err := testClient.Table("exampleTable")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = testClient.TableInfo("exampleTable")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = testClient.TableConstraints("exampleTable")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = testClient.TableIndexes("exampleTable")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = testClient.TableRowsCount("exampleTable", RowsOptions{})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = testClient.EstimatedTableRowsCount("exampleTable", RowsOptions{})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testQuery(t *testing.T) {
|
||||||
|
t.Run("basic query", func(t *testing.T) {
|
||||||
|
res, err := testClient.Query("SELECT * FROM books")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 4, len(res.Columns))
|
||||||
|
assert.Equal(t, 15, len(res.Rows))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("error", func(t *testing.T) {
|
||||||
|
res, err := testClient.Query("SELCT * FROM books")
|
||||||
|
assert.NotNil(t, err)
|
||||||
|
assert.Equal(t, "pq: syntax error at or near \"SELCT\"", err.Error())
|
||||||
|
assert.Nil(t, res)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid table", func(t *testing.T) {
|
||||||
|
res, err := testClient.Query("SELECT * FROM books2")
|
||||||
|
assert.NotNil(t, err)
|
||||||
|
assert.Equal(t, "pq: relation \"books2\" does not exist", err.Error())
|
||||||
|
assert.Nil(t, res)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("timeout", func(t *testing.T) {
|
||||||
|
testClient.queryTimeout = time.Millisecond * 100
|
||||||
|
defer func() {
|
||||||
|
testClient.queryTimeout = 0
|
||||||
|
}()
|
||||||
|
|
||||||
|
res, err := testClient.query("SELECT pg_sleep(1);")
|
||||||
|
assert.Equal(t, "pq: canceling statement due to user request", err.Error())
|
||||||
|
assert.Nil(t, res)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUpdateQuery(t *testing.T) {
|
||||||
|
t.Run("updating data", func(t *testing.T) {
|
||||||
|
// Add new row
|
||||||
|
testClient.db.MustExec("INSERT INTO books (id, title) VALUES (8888, 'Test Book'), (8889, 'Test Book 2')")
|
||||||
|
|
||||||
|
// Update without return values
|
||||||
|
res, err := testClient.Query("UPDATE books SET title = 'Foo' WHERE id >= 8888 AND id <= 8889")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "Rows Affected", res.Columns[0])
|
||||||
|
assert.Equal(t, int64(2), res.Rows[0][0])
|
||||||
|
|
||||||
|
// Update with return values
|
||||||
|
res, err = testClient.Query("UPDATE books SET title = 'Foo2' WHERE id >= 8888 AND id <= 8889 RETURNING id, title")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"id", "title"}, res.Columns)
|
||||||
|
assert.Equal(t, Row{int64(8888), "Foo2"}, res.Rows[0])
|
||||||
|
assert.Equal(t, Row{int64(8889), "Foo2"}, res.Rows[1])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("deleting data", func(t *testing.T) {
|
||||||
|
// Add new row
|
||||||
|
testClient.db.MustExec("INSERT INTO books (id, title) VALUES (9999, 'Test Book')")
|
||||||
|
|
||||||
|
// Delete the existing row
|
||||||
|
res, err := testClient.Query("DELETE FROM books WHERE id = 9999")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "Rows Affected", res.Columns[0])
|
||||||
|
assert.Equal(t, int64(1), res.Rows[0][0])
|
||||||
|
|
||||||
|
// Deleting already deleted row
|
||||||
|
res, err = testClient.Query("DELETE FROM books WHERE id = 9999")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(0), res.Rows[0][0])
|
||||||
|
|
||||||
|
// Delete with returning value
|
||||||
|
testClient.db.MustExec("INSERT INTO books (id, title) VALUES (9999, 'Test Book')")
|
||||||
|
|
||||||
|
res, err = testClient.Query("DELETE FROM books WHERE id = 9999 RETURNING id")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(9999), res.Rows[0][0])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTableRowsOrderEscape(t *testing.T) {
|
||||||
|
rows, err := testClient.TableRows("dummies", RowsOptions{SortColumn: "isDummy"})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 2, len(rows.Rows))
|
||||||
|
|
||||||
|
rows, err = testClient.TableRows("dummies", RowsOptions{SortColumn: "isdummy"})
|
||||||
|
assert.NotNil(t, err)
|
||||||
|
assert.Equal(t, `pq: column "isdummy" does not exist`, err.Error())
|
||||||
|
assert.Nil(t, rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFunctions(t *testing.T) {
|
||||||
|
funcName := "get_customer_name"
|
||||||
|
funcID := ""
|
||||||
|
|
||||||
|
res, err := testClient.Objects()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
for _, row := range res.Rows {
|
||||||
|
if row[2] == funcName {
|
||||||
|
funcID = row[0].(string)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err = testClient.Function("12345")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assertMatches(t, []string{"oid", "proname", "functiondef"}, res.Columns)
|
||||||
|
assert.Equal(t, 0, len(res.Rows))
|
||||||
|
|
||||||
|
res, err = testClient.Function(funcID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assertMatches(t, []string{"oid", "proname", "functiondef"}, res.Columns)
|
||||||
|
assert.Equal(t, 1, len(res.Rows))
|
||||||
|
assert.Equal(t, funcName, res.Rows[0][1])
|
||||||
|
assert.Contains(t, res.Rows[0][len(res.Columns)-1], "SELECT INTO customer_fname, customer_lname")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testResult(t *testing.T) {
|
||||||
|
t.Run("json", func(t *testing.T) {
|
||||||
|
result, err := testClient.Query("SELECT * FROM books LIMIT 1")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, `[{"author_id":4156,"id":7808,"subject_id":9,"title":"The Shining"}]`, string(result.JSON()))
|
||||||
|
|
||||||
|
result, err = testClient.Query("SELECT 'NaN'::float AS value;")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, `[{"value":null}]`, string(result.JSON()))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("csv", func(t *testing.T) {
|
||||||
|
expected := "id,title,author_id,subject_id\n156,The Tell-Tale Heart,115,9\n"
|
||||||
|
|
||||||
|
res, err := testClient.Query("SELECT * FROM books ORDER BY id ASC LIMIT 1")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, expected, string(res.CSV()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHistory(t *testing.T) {
|
||||||
|
t.Run("success", func(t *testing.T) {
|
||||||
|
_, err := testClient.Query("SELECT * FROM books WHERE id = 12345")
|
||||||
|
query := testClient.History[len(testClient.History)-1].Query
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "SELECT * FROM books WHERE id = 12345", query)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("failed query", func(t *testing.T) {
|
||||||
|
_, err := testClient.Query("SELECT * FROM books123")
|
||||||
|
query := testClient.History[len(testClient.History)-1].Query
|
||||||
|
assert.NotNil(t, err)
|
||||||
|
assert.NotEqual(t, "SELECT * FROM books123", query)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unique queries", func(t *testing.T) {
|
||||||
|
url := fmt.Sprintf("postgres://%s@%s:%s/%s?sslmode=disable", serverUser, serverHost, serverPort, serverDatabase)
|
||||||
|
|
||||||
|
client, _ := NewFromUrl(url, nil)
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
_, err := client.Query("SELECT * FROM books WHERE id = 1")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, 1, len(client.History))
|
||||||
|
assert.Equal(t, "SELECT * FROM books WHERE id = 1", client.History[0].Query)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func testReadOnlyMode(t *testing.T) {
|
||||||
|
command.Opts.ReadOnly = true
|
||||||
|
defer func() {
|
||||||
|
command.Opts.ReadOnly = false
|
||||||
|
}()
|
||||||
|
|
||||||
|
url := fmt.Sprintf("postgres://%s@%s:%s/%s?sslmode=disable", serverUser, serverHost, serverPort, serverDatabase)
|
||||||
|
client, _ := NewFromUrl(url, nil)
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
err := client.SetReadOnlyMode()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = client.Query("\nCREATE TABLE foobar(id integer);\n")
|
||||||
|
assert.NotNil(t, err)
|
||||||
|
assert.Error(t, err, "query contains keywords not allowed in read-only mode")
|
||||||
|
|
||||||
|
// Turn off guard
|
||||||
|
_, err = client.db.Exec("SET default_transaction_read_only=off;")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = client.Query("\nCREATE TABLE foobar(id integer);\n")
|
||||||
|
assert.NotNil(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "query contains keywords not allowed in read-only mode")
|
||||||
|
|
||||||
|
_, err = client.Query("-- CREATE TABLE foobar(id integer);\nSELECT 'foo';")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = client.Query("/* CREATE TABLE foobar(id integer); */ SELECT 'foo';")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTablesStats(t *testing.T) {
|
||||||
|
columns := []string{
|
||||||
|
"schema_name",
|
||||||
|
"table_name",
|
||||||
|
"total_size",
|
||||||
|
"data_size",
|
||||||
|
"index_size",
|
||||||
|
"estimated_rows_count",
|
||||||
|
"estimated_rows",
|
||||||
|
"index_to_data_ratio",
|
||||||
|
"indexes_count",
|
||||||
|
"columns_count",
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := testClient.TablesStats()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, columns, result.Columns)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testConnContext(t *testing.T) {
|
||||||
|
result, err := testClient.GetConnContext()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "localhost", result.Host)
|
||||||
|
assert.Equal(t, "postgres", result.User)
|
||||||
|
assert.Equal(t, "booktown", result.Database)
|
||||||
|
assert.Equal(t, "default", result.Mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAll(t *testing.T) {
|
||||||
|
if onWindows() {
|
||||||
|
t.Log("Unit testing on Windows platform is not supported.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
initVars()
|
||||||
|
setupCommands()
|
||||||
|
teardown(t, false)
|
||||||
|
setup()
|
||||||
|
setupClient()
|
||||||
|
|
||||||
|
testNewClientFromURL(t)
|
||||||
|
testClientIdleTime(t)
|
||||||
|
testTest(t)
|
||||||
|
testInfo(t)
|
||||||
|
testActivity(t)
|
||||||
|
testDatabases(t)
|
||||||
|
testSchemas(t)
|
||||||
|
testObjects(t)
|
||||||
|
testTable(t)
|
||||||
|
testTableRows(t)
|
||||||
|
testTableInfo(t)
|
||||||
|
testEstimatedTableRowsCount(t)
|
||||||
|
testTableRowsCount(t)
|
||||||
|
testTableRowsCountWithLargeTable(t)
|
||||||
|
testTableIndexes(t)
|
||||||
|
testTableConstraints(t)
|
||||||
|
testTableNameWithCamelCase(t)
|
||||||
|
testQuery(t)
|
||||||
|
testUpdateQuery(t)
|
||||||
|
testTableRowsOrderEscape(t)
|
||||||
|
testFunctions(t)
|
||||||
|
testResult(t)
|
||||||
|
testHistory(t)
|
||||||
|
testReadOnlyMode(t)
|
||||||
|
testDumpExport(t)
|
||||||
|
testTablesStats(t)
|
||||||
|
testConnContext(t)
|
||||||
|
|
||||||
|
teardownClient()
|
||||||
|
teardown(t, true)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/mr-tron/base58"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
CodecNone = "none"
|
||||||
|
CodecHex = "hex"
|
||||||
|
CodecBase58 = "base58"
|
||||||
|
CodecBase64 = "base64"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// BinaryEncodingFormat specifies the default serialization format of binary data
|
||||||
|
BinaryCodec = CodecBase64
|
||||||
|
)
|
||||||
|
|
||||||
|
func SetBinaryCodec(codec string) error {
|
||||||
|
switch codec {
|
||||||
|
case CodecNone, CodecHex, CodecBase58, CodecBase64:
|
||||||
|
BinaryCodec = codec
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid binary codec: %v", codec)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeBinaryData(data []byte, codec string) string {
|
||||||
|
switch codec {
|
||||||
|
case CodecHex:
|
||||||
|
return hex.EncodeToString(data)
|
||||||
|
case CodecBase58:
|
||||||
|
return base58.Encode(data)
|
||||||
|
case CodecBase64:
|
||||||
|
return base64.StdEncoding.EncodeToString(data)
|
||||||
|
default:
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSetBinaryCodec(t *testing.T) {
|
||||||
|
examples := []struct {
|
||||||
|
input string
|
||||||
|
err error
|
||||||
|
}{
|
||||||
|
{input: CodecNone, err: nil},
|
||||||
|
{input: CodecBase58, err: nil},
|
||||||
|
{input: CodecBase64, err: nil},
|
||||||
|
{input: CodecHex, err: nil},
|
||||||
|
{input: "foobar", err: errors.New("invalid binary codec: foobar")},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ex := range examples {
|
||||||
|
t.Run(ex.input, func(t *testing.T) {
|
||||||
|
val := BinaryCodec
|
||||||
|
defer func() {
|
||||||
|
BinaryCodec = val
|
||||||
|
}()
|
||||||
|
|
||||||
|
assert.Equal(t, ex.err, SetBinaryCodec(ex.input))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_encodeBinaryData(t *testing.T) {
|
||||||
|
examples := []struct {
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
encoding string
|
||||||
|
}{
|
||||||
|
{input: "hello world", expected: "hello world", encoding: CodecNone},
|
||||||
|
{input: "hello world", expected: "StV1DL6CwTryKyV", encoding: CodecBase58},
|
||||||
|
{input: "hello world", expected: "aGVsbG8gd29ybGQ=", encoding: CodecBase64},
|
||||||
|
{input: "hello world", expected: "68656c6c6f20776f726c64", encoding: CodecHex},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ex := range examples {
|
||||||
|
t.Run(ex.input, func(t *testing.T) {
|
||||||
|
assert.Equal(t, ex.expected, encodeBinaryData([]byte(ex.input), ex.encoding))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/url"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
unsupportedDumpOptions = []string{
|
||||||
|
"search_path",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Dump represents a database dump
|
||||||
|
type Dump struct {
|
||||||
|
Table string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks availability and version of pg_dump CLI
|
||||||
|
func (d *Dump) Validate(serverVersion string) error {
|
||||||
|
out := bytes.NewBuffer(nil)
|
||||||
|
|
||||||
|
cmd := exec.Command("pg_dump", "--version")
|
||||||
|
cmd.Stdout = out
|
||||||
|
cmd.Stderr = out
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("pg_dump command failed: %s", out.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
detected, dumpVersion := detectDumpVersion(out.String())
|
||||||
|
if detected && serverVersion != "" {
|
||||||
|
satisfied := checkVersionRequirement(dumpVersion, serverVersion)
|
||||||
|
if !satisfied {
|
||||||
|
return fmt.Errorf("pg_dump version %v not compatible with server version %v", dumpVersion, serverVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export streams the database dump to the specified writer
|
||||||
|
func (d *Dump) Export(ctx context.Context, connstr string, writer io.Writer) error {
|
||||||
|
if str, err := removeUnsupportedOptions(connstr); err != nil {
|
||||||
|
return err
|
||||||
|
} else {
|
||||||
|
connstr = str
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := []string{
|
||||||
|
"--no-owner", // skip restoration of object ownership in plain-text format
|
||||||
|
"--clean", // clean (drop) database objects before recreating
|
||||||
|
"--compress", "6", // compression level for compressed formats
|
||||||
|
}
|
||||||
|
|
||||||
|
if d.Table != "" {
|
||||||
|
opts = append(opts, []string{"--table", d.Table}...)
|
||||||
|
}
|
||||||
|
|
||||||
|
opts = append(opts, connstr)
|
||||||
|
errOutput := bytes.NewBuffer(nil)
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "pg_dump", opts...)
|
||||||
|
cmd.Stdout = writer
|
||||||
|
cmd.Stderr = errOutput
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("error: %s. output: %s", err.Error(), errOutput.Bytes())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeUnsupportedOptions removes any options unsupported for making a db dump
|
||||||
|
func removeUnsupportedOptions(input string) (string, error) {
|
||||||
|
uri, err := url.Parse(input)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
q := uri.Query()
|
||||||
|
for _, opt := range unsupportedDumpOptions {
|
||||||
|
q.Del(opt)
|
||||||
|
q.Del(strings.ToUpper(opt))
|
||||||
|
}
|
||||||
|
uri.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
return uri.String(), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testDumpExport(t *testing.T) {
|
||||||
|
url := fmt.Sprintf("postgres://%s@%s:%s/%s?sslmode=disable", serverUser, serverHost, serverPort, serverDatabase)
|
||||||
|
|
||||||
|
savePath := "/tmp/dump.sql.gz"
|
||||||
|
os.Remove(savePath)
|
||||||
|
|
||||||
|
saveFile, err := os.Create(savePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
saveFile.Close()
|
||||||
|
os.Remove(savePath)
|
||||||
|
}()
|
||||||
|
|
||||||
|
dump := Dump{}
|
||||||
|
|
||||||
|
// Test for pg_dump presence
|
||||||
|
assert.NoError(t, dump.Validate("10.0"))
|
||||||
|
assert.NoError(t, dump.Validate(""))
|
||||||
|
assert.Contains(t, dump.Validate("20").Error(), "not compatible with server version 20")
|
||||||
|
|
||||||
|
// Test full db dump
|
||||||
|
err = dump.Export(context.Background(), url, saveFile)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Test nonexistent database
|
||||||
|
invalidURL := fmt.Sprintf("postgres://%s@%s:%s/%s?sslmode=disable", serverUser, serverHost, serverPort, "foobar")
|
||||||
|
err = dump.Export(context.Background(), invalidURL, saveFile)
|
||||||
|
assert.Contains(t, err.Error(), `database "foobar" does not exist`)
|
||||||
|
|
||||||
|
// Test dump of non existent db
|
||||||
|
dump = Dump{Table: "foobar"}
|
||||||
|
err = dump.Export(context.Background(), url, saveFile)
|
||||||
|
assert.NotNil(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "no matching tables were found")
|
||||||
|
|
||||||
|
// Should drop "search_path" param from URI
|
||||||
|
dump = Dump{}
|
||||||
|
searchPathURL := fmt.Sprintf("postgres://%s@%s:%s/%s?sslmode=disable&search_path=private", serverUser, serverHost, serverPort, serverDatabase)
|
||||||
|
err = dump.Export(context.Background(), searchPathURL, saveFile)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/csv"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sosedoff/pgweb/pkg/command"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ObjTypeTable = "table"
|
||||||
|
ObjTypeView = "view"
|
||||||
|
ObjTypeMaterializedView = "materialized_view"
|
||||||
|
ObjTypeSequence = "sequence"
|
||||||
|
ObjTypeFunction = "function"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
// Row represents a single row of data
|
||||||
|
Row []interface{}
|
||||||
|
|
||||||
|
// RowsOptions contains a list of parameters for table browsing requests
|
||||||
|
RowsOptions struct {
|
||||||
|
Where string // Custom filter
|
||||||
|
Offset int // Number of rows to skip
|
||||||
|
Limit int // Number of rows to fetch
|
||||||
|
SortColumn string // Column to sort by
|
||||||
|
SortOrder string // Sort direction (ASC, DESC)
|
||||||
|
}
|
||||||
|
|
||||||
|
Pagination struct {
|
||||||
|
Rows int64 `json:"rows_count"`
|
||||||
|
Page int64 `json:"page"`
|
||||||
|
Pages int64 `json:"pages_count"`
|
||||||
|
PerPage int64 `json:"per_page"`
|
||||||
|
}
|
||||||
|
|
||||||
|
Result struct {
|
||||||
|
Pagination *Pagination `json:"pagination,omitempty"`
|
||||||
|
Columns []string `json:"columns"`
|
||||||
|
Rows []Row `json:"rows"`
|
||||||
|
Stats *ResultStats `json:"stats,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
ResultStats struct {
|
||||||
|
ColumnsCount int `json:"columns_count"`
|
||||||
|
RowsCount int `json:"rows_count"`
|
||||||
|
RowsAffected int64 `json:"rows_affected"`
|
||||||
|
QueryStartTime time.Time `json:"query_start_time"`
|
||||||
|
QueryFinishTime time.Time `json:"query_finish_time"`
|
||||||
|
QueryDuration int64 `json:"query_duration_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
Object struct {
|
||||||
|
OID string `json:"oid"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
Objects struct {
|
||||||
|
Tables []Object `json:"table"`
|
||||||
|
Views []Object `json:"view"`
|
||||||
|
MaterializedViews []Object `json:"materialized_view"`
|
||||||
|
Functions []Object `json:"function"`
|
||||||
|
Sequences []Object `json:"sequence"`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Due to big int number limitations in javascript, numbers should be encoded
|
||||||
|
// as strings so they could be properly loaded on the frontend.
|
||||||
|
func (res *Result) PostProcess() {
|
||||||
|
for i, row := range res.Rows {
|
||||||
|
for j, col := range row {
|
||||||
|
if col == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch val := col.(type) {
|
||||||
|
case int64:
|
||||||
|
if val < -9007199254740991 || val > 9007199254740991 {
|
||||||
|
res.Rows[i][j] = strconv.FormatInt(col.(int64), 10)
|
||||||
|
}
|
||||||
|
case float64:
|
||||||
|
// json.Marshal panics when dealing with NaN/Inf values
|
||||||
|
// issue: https://github.com/golang/go/issues/25721
|
||||||
|
if math.IsNaN(val) {
|
||||||
|
res.Rows[i][j] = nil
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if val < -999999999999999 || val > 999999999999999 {
|
||||||
|
res.Rows[i][j] = strconv.FormatFloat(val, 'e', -1, 64)
|
||||||
|
}
|
||||||
|
case string:
|
||||||
|
if hasBinary(val, 8) && BinaryCodec != CodecNone {
|
||||||
|
res.Rows[i][j] = encodeBinaryData([]byte(val), BinaryCodec)
|
||||||
|
}
|
||||||
|
case time.Time:
|
||||||
|
// RFC 3339 is clear that years are 4 digits exactly.
|
||||||
|
// See golang.org/issue/4556#c15 for more discussion.
|
||||||
|
if val.Year() < 0 || val.Year() >= 10000 {
|
||||||
|
res.Rows[i][j] = "ERR: INVALID_DATE"
|
||||||
|
} else {
|
||||||
|
res.Rows[i][j] = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (res *Result) Format() []map[string]interface{} {
|
||||||
|
items := make([]map[string]interface{}, len(res.Rows))
|
||||||
|
|
||||||
|
for rowIdx, row := range res.Rows {
|
||||||
|
item := make(map[string]interface{})
|
||||||
|
for i, c := range res.Columns {
|
||||||
|
item[c] = row[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
items[rowIdx] = item
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func (res *Result) CSV() []byte {
|
||||||
|
buff := &bytes.Buffer{}
|
||||||
|
writer := csv.NewWriter(buff)
|
||||||
|
|
||||||
|
if err := writer.Write(res.Columns); err != nil {
|
||||||
|
log.Printf("result csv write error: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, row := range res.Rows {
|
||||||
|
record := make([]string, len(res.Columns))
|
||||||
|
|
||||||
|
for i, item := range row {
|
||||||
|
switch v := item.(type) {
|
||||||
|
case time.Time:
|
||||||
|
record[i] = v.Format("2006-01-02 15:04:05")
|
||||||
|
case nil:
|
||||||
|
record[i] = ""
|
||||||
|
default:
|
||||||
|
record[i] = fmt.Sprintf("%v", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := writer.Write(record)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.Flush()
|
||||||
|
return buff.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (res *Result) JSON() []byte {
|
||||||
|
var data []byte
|
||||||
|
|
||||||
|
if command.Opts.DisablePrettyJSON {
|
||||||
|
data, _ = json.Marshal(res.Format())
|
||||||
|
} else {
|
||||||
|
data, _ = json.MarshalIndent(res.Format(), "", " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func ObjectsFromResult(res *Result) map[string]*Objects {
|
||||||
|
objects := map[string]*Objects{}
|
||||||
|
|
||||||
|
for _, row := range res.Rows {
|
||||||
|
oid := row[0].(string)
|
||||||
|
schema := row[1].(string)
|
||||||
|
name := row[2].(string)
|
||||||
|
objectType := row[3].(string)
|
||||||
|
|
||||||
|
if objects[schema] == nil {
|
||||||
|
objects[schema] = &Objects{
|
||||||
|
Tables: []Object{},
|
||||||
|
Views: []Object{},
|
||||||
|
MaterializedViews: []Object{},
|
||||||
|
Functions: []Object{},
|
||||||
|
Sequences: []Object{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
obj := Object{OID: oid, Name: name}
|
||||||
|
|
||||||
|
switch objectType {
|
||||||
|
case ObjTypeTable:
|
||||||
|
objects[schema].Tables = append(objects[schema].Tables, obj)
|
||||||
|
case ObjTypeView:
|
||||||
|
objects[schema].Views = append(objects[schema].Views, obj)
|
||||||
|
case ObjTypeMaterializedView:
|
||||||
|
objects[schema].MaterializedViews = append(objects[schema].MaterializedViews, obj)
|
||||||
|
case ObjTypeFunction:
|
||||||
|
objects[schema].Functions = append(objects[schema].Functions, obj)
|
||||||
|
case ObjTypeSequence:
|
||||||
|
objects[schema].Sequences = append(objects[schema].Sequences, obj)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return objects
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sosedoff/pgweb/pkg/command"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPostProcess(t *testing.T) {
|
||||||
|
t.Run("large numbers", func(t *testing.T) {
|
||||||
|
result := Result{
|
||||||
|
Columns: []string{"value"},
|
||||||
|
Rows: []Row{
|
||||||
|
{int(1234)},
|
||||||
|
{int64(9223372036854775807)},
|
||||||
|
{int64(-9223372036854775808)},
|
||||||
|
{float64(9223372036854775808.9223372036854775808)},
|
||||||
|
{float64(999999999999999.9)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result.PostProcess()
|
||||||
|
|
||||||
|
assert.Equal(t, 1234, result.Rows[0][0])
|
||||||
|
assert.Equal(t, "9223372036854775807", result.Rows[1][0])
|
||||||
|
assert.Equal(t, "-9223372036854775808", result.Rows[2][0])
|
||||||
|
assert.Equal(t, "9.223372036854776e+18", result.Rows[3][0])
|
||||||
|
assert.Equal(t, "9.999999999999999e+14", result.Rows[4][0])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("binary encoding", func(t *testing.T) {
|
||||||
|
result := Result{
|
||||||
|
Columns: []string{"data"},
|
||||||
|
Rows: []Row{
|
||||||
|
{"text value"},
|
||||||
|
{"text with symbols !@#$%"},
|
||||||
|
{string([]byte{10, 11, 12, 13})},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result.PostProcess()
|
||||||
|
|
||||||
|
assert.Equal(t, "text value", result.Rows[0][0])
|
||||||
|
assert.Equal(t, "text with symbols !@#$%", result.Rows[1][0])
|
||||||
|
assert.Equal(t, "CgsMDQ==", result.Rows[2][0])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCSV(t *testing.T) {
|
||||||
|
result := Result{
|
||||||
|
Columns: []string{"id", "name", "email", "extra"},
|
||||||
|
Rows: []Row{
|
||||||
|
{1, "John", "john@example.com", "data"},
|
||||||
|
{2, "Bob", "bob@example.com", nil},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := strings.Join([]string{
|
||||||
|
"id,name,email,extra",
|
||||||
|
"1,John,john@example.com,data",
|
||||||
|
"2,Bob,bob@example.com,",
|
||||||
|
}, "\n") + "\n"
|
||||||
|
|
||||||
|
assert.Equal(t, expected, string(result.CSV()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSON(t *testing.T) {
|
||||||
|
result := Result{
|
||||||
|
Columns: []string{"id", "name", "email"},
|
||||||
|
Rows: []Row{
|
||||||
|
{1, "John", "john@example.com"},
|
||||||
|
{2, "Bob", "bob@example.com"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
obj := []struct {
|
||||||
|
Id int
|
||||||
|
Name string
|
||||||
|
Email string
|
||||||
|
}{}
|
||||||
|
|
||||||
|
expected := []struct {
|
||||||
|
Id int
|
||||||
|
Name string
|
||||||
|
Email string
|
||||||
|
}{
|
||||||
|
{1, "John", "john@example.com"},
|
||||||
|
{2, "Bob", "bob@example.com"},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.NoError(t, json.Unmarshal(result.JSON(), &obj))
|
||||||
|
assert.Equal(t, 2, len(obj))
|
||||||
|
assert.Equal(t, expected, obj)
|
||||||
|
|
||||||
|
t.Run("invalid time", func(t *testing.T) {
|
||||||
|
loc, err := time.LoadLocation("UTC")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
command.Opts.DisablePrettyJSON = true
|
||||||
|
defer func() {
|
||||||
|
command.Opts.DisablePrettyJSON = false
|
||||||
|
}()
|
||||||
|
|
||||||
|
result := Result{
|
||||||
|
Columns: []string{"value"},
|
||||||
|
Rows: []Row{
|
||||||
|
{time.Unix(1640995200, 0).In(loc)},
|
||||||
|
{time.Unix(222539616000, 0).In(loc)},
|
||||||
|
{time.Unix(254096611200, 0).In(loc)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result.PostProcess()
|
||||||
|
assert.Equal(t, `[{"value":"2022-01-01T00:00:00Z"},{"value":"9022-01-01T00:00:00Z"},{"value":"ERR: INVALID_DATE"}]`, string(result.JSON()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResultFormat(t *testing.T) {
|
||||||
|
result := Result{
|
||||||
|
Columns: []string{"col1", "col2", "col3", "col4"},
|
||||||
|
Rows: []Row{
|
||||||
|
{"1", "2", "3", nil},
|
||||||
|
{"4", "5", "6", nil},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := []map[string]interface{}{
|
||||||
|
{"col1": "1", "col2": "2", "col3": "3", "col4": nil},
|
||||||
|
{"col1": "4", "col2": "5", "col3": "6", "col4": nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, expected, result.Format())
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ScaleFT/sshkeys"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
|
||||||
|
"github.com/sosedoff/pgweb/pkg/connection"
|
||||||
|
"github.com/sosedoff/pgweb/pkg/shared"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
portStart = 29168
|
||||||
|
portLimit = 500
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tunnel represents the connection between local and remote server
|
||||||
|
type Tunnel struct {
|
||||||
|
TargetHost string
|
||||||
|
TargetPort string
|
||||||
|
Port int
|
||||||
|
SSHInfo *shared.SSHInfo
|
||||||
|
Config *ssh.ClientConfig
|
||||||
|
Client *ssh.Client
|
||||||
|
Listener *net.TCPListener
|
||||||
|
}
|
||||||
|
|
||||||
|
func privateKeyPath() string {
|
||||||
|
return os.Getenv("HOME") + "/.ssh/id_rsa"
|
||||||
|
}
|
||||||
|
|
||||||
|
func expandKeyPath(path string) string {
|
||||||
|
home := os.Getenv("HOME")
|
||||||
|
if home == "" {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
return strings.Replace(path, "~", home, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileExists(path string) bool {
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePrivateKey(keyPath string, keyPass string) (ssh.Signer, error) {
|
||||||
|
buff, err := os.ReadFile(keyPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
signer, err := ssh.ParsePrivateKey(buff)
|
||||||
|
if _, ok := err.(*ssh.PassphraseMissingError); ok {
|
||||||
|
if keyPass == "" {
|
||||||
|
return nil, errors.New("SSH key password is not provided")
|
||||||
|
}
|
||||||
|
return sshkeys.ParseEncryptedPrivateKey(buff, []byte(keyPass))
|
||||||
|
}
|
||||||
|
|
||||||
|
return signer, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeConfig(info *shared.SSHInfo) (*ssh.ClientConfig, error) {
|
||||||
|
methods := []ssh.AuthMethod{}
|
||||||
|
|
||||||
|
// Try to use user-provided key, fallback to system default key
|
||||||
|
keyPath := info.Key
|
||||||
|
if keyPath == "" {
|
||||||
|
keyPath = privateKeyPath()
|
||||||
|
} else {
|
||||||
|
keyPath = expandKeyPath(keyPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !fileExists(keyPath) {
|
||||||
|
return nil, errors.New("ssh public key not found at " + keyPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append public key authentication method
|
||||||
|
key, err := parsePrivateKey(keyPath, info.KeyPassword)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
methods = append(methods, ssh.PublicKeys(key))
|
||||||
|
|
||||||
|
// Append password authentication method
|
||||||
|
if info.Password != "" {
|
||||||
|
methods = append(methods, ssh.Password(info.Password))
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &ssh.ClientConfig{
|
||||||
|
User: info.User,
|
||||||
|
Auth: methods,
|
||||||
|
Timeout: time.Second * 10,
|
||||||
|
HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tunnel *Tunnel) sshEndpoint() string {
|
||||||
|
return fmt.Sprintf("%s:%v", tunnel.SSHInfo.Host, tunnel.SSHInfo.Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tunnel *Tunnel) targetEndpoint() string {
|
||||||
|
return fmt.Sprintf("%v:%v", tunnel.TargetHost, tunnel.TargetPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tunnel *Tunnel) copy(wg *sync.WaitGroup, writer, reader net.Conn) {
|
||||||
|
defer wg.Done()
|
||||||
|
if _, err := io.Copy(writer, reader); err != nil {
|
||||||
|
log.Println("Tunnel copy error:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tunnel *Tunnel) handleConnection(local net.Conn) {
|
||||||
|
remote, err := tunnel.Client.Dial("tcp", tunnel.targetEndpoint())
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wg := sync.WaitGroup{}
|
||||||
|
wg.Add(2)
|
||||||
|
|
||||||
|
go tunnel.copy(&wg, local, remote)
|
||||||
|
go tunnel.copy(&wg, remote, local)
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
local.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the tunnel connection
|
||||||
|
func (tunnel *Tunnel) Close() {
|
||||||
|
if tunnel.Client != nil {
|
||||||
|
tunnel.Client.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if tunnel.Listener != nil {
|
||||||
|
tunnel.Listener.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure establishes the tunnel between localhost and remote machine
|
||||||
|
func (tunnel *Tunnel) Configure() error {
|
||||||
|
config, err := makeConfig(tunnel.SSHInfo)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tunnel.Config = config
|
||||||
|
|
||||||
|
client, err := ssh.Dial("tcp", tunnel.sshEndpoint(), config)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tunnel.Client = client
|
||||||
|
|
||||||
|
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%v", tunnel.Port))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tunnel.Listener = listener.(*net.TCPListener)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts the connection handler loop
|
||||||
|
func (tunnel *Tunnel) Start() {
|
||||||
|
defer tunnel.Close()
|
||||||
|
|
||||||
|
for {
|
||||||
|
conn, err := tunnel.Listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go tunnel.handleConnection(conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTunnel instantiates a new tunnel struct from given ssh info
|
||||||
|
func NewTunnel(sshInfo *shared.SSHInfo, dbUrl string) (*Tunnel, error) {
|
||||||
|
uri, err := url.Parse(dbUrl)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
listenPort, err := connection.FindAvailablePort(portStart, portLimit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks := strings.Split(uri.Host, ":")
|
||||||
|
host := chunks[0]
|
||||||
|
port := "5432"
|
||||||
|
|
||||||
|
if len(chunks) == 2 {
|
||||||
|
port = chunks[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
tunnel := &Tunnel{
|
||||||
|
Port: listenPort,
|
||||||
|
SSHInfo: sshInfo,
|
||||||
|
TargetHost: host,
|
||||||
|
TargetPort: port,
|
||||||
|
}
|
||||||
|
|
||||||
|
return tunnel, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// List of keywords that are not allowed in read-only mode
|
||||||
|
reRestrictedKeywords = regexp.MustCompile(`(?mi)\s?(CREATE|INSERT|DROP|DELETE|TRUNCATE|GRANT|OPEN|IMPORT|COPY)\s`)
|
||||||
|
|
||||||
|
// Comment regular expressions
|
||||||
|
reSlashComment = regexp.MustCompile(`(?m)/\*.+\*/`)
|
||||||
|
reDashComment = regexp.MustCompile(`(?m)--.+`)
|
||||||
|
|
||||||
|
// Postgres version signature
|
||||||
|
postgresSignature = regexp.MustCompile(`(?i)postgresql ([\d\.]+)\s?`)
|
||||||
|
postgresDumpSignature = regexp.MustCompile(`\s([\d\.]+)\s?`)
|
||||||
|
postgresType = "PostgreSQL"
|
||||||
|
|
||||||
|
// Cockroach version signature
|
||||||
|
cockroachSignature = regexp.MustCompile(`(?i)cockroachdb ccl v([\d\.]+)\s?`)
|
||||||
|
cockroachType = "CockroachDB"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get major and minor version components
|
||||||
|
// Example: 10.2.3.1 -> 10.2
|
||||||
|
func getMajorMinorVersion(str string) (major int, minor int) {
|
||||||
|
chunks := strings.Split(str, ".")
|
||||||
|
fmt.Sscanf(chunks[0], "%d", &major)
|
||||||
|
if len(chunks) > 1 {
|
||||||
|
fmt.Sscanf(chunks[1], "%d", &minor)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get short version from the string
|
||||||
|
// Example: 10.2.3.1 -> 10.2
|
||||||
|
func getMajorMinorVersionString(str string) string {
|
||||||
|
major, minor := getMajorMinorVersion(str)
|
||||||
|
return fmt.Sprintf("%d.%d", major, minor)
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectServerTypeAndVersion(version string) (bool, string, string) {
|
||||||
|
version = strings.TrimSpace(version)
|
||||||
|
|
||||||
|
// Detect postgresql
|
||||||
|
matches := postgresSignature.FindAllStringSubmatch(version, 1)
|
||||||
|
if len(matches) > 0 {
|
||||||
|
return true, postgresType, matches[0][1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect cockroachdb
|
||||||
|
matches = cockroachSignature.FindAllStringSubmatch(version, 1)
|
||||||
|
if len(matches) > 0 {
|
||||||
|
return true, cockroachType, matches[0][1]
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectDumpVersion parses out version from `pg_dump -V` command.
|
||||||
|
func detectDumpVersion(version string) (bool, string) {
|
||||||
|
matches := postgresDumpSignature.FindAllStringSubmatch(version, 1)
|
||||||
|
if len(matches) > 0 {
|
||||||
|
return true, matches[0][1]
|
||||||
|
}
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkVersionRequirement(client, server string) bool {
|
||||||
|
clientMajor, clientMinor := getMajorMinorVersion(client)
|
||||||
|
serverMajor, serverMinor := getMajorMinorVersion(server)
|
||||||
|
|
||||||
|
if serverMajor < 10 {
|
||||||
|
return clientMajor >= serverMajor && clientMinor >= serverMinor
|
||||||
|
}
|
||||||
|
|
||||||
|
return clientMajor >= serverMajor
|
||||||
|
}
|
||||||
|
|
||||||
|
// containsRestrictedKeywords returns true if given keyword is not allowed in read-only mode
|
||||||
|
func containsRestrictedKeywords(str string) bool {
|
||||||
|
str = reSlashComment.ReplaceAllString(str, "")
|
||||||
|
str = reDashComment.ReplaceAllString(str, "")
|
||||||
|
|
||||||
|
return reRestrictedKeywords.MatchString(str)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasBinary(data string, checkLen int) bool {
|
||||||
|
for idx, chr := range data {
|
||||||
|
if int(chr) < 32 || int(chr) > 126 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if idx >= checkLen {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDetectServerType(t *testing.T) {
|
||||||
|
examples := []struct {
|
||||||
|
input string
|
||||||
|
match bool
|
||||||
|
serverType string
|
||||||
|
version string
|
||||||
|
}{
|
||||||
|
{input: "",
|
||||||
|
match: false,
|
||||||
|
serverType: "",
|
||||||
|
version: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: " postgresql 15 ",
|
||||||
|
match: true,
|
||||||
|
serverType: postgresType,
|
||||||
|
version: "15",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "PostgreSQL 14.5 (Homebrew) on aarch64-apple-darwin21.6.0",
|
||||||
|
match: true,
|
||||||
|
serverType: postgresType,
|
||||||
|
version: "14.5",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "PostgreSQL 11.16, compiled by Visual C++ build 1800, 64-bit",
|
||||||
|
match: true,
|
||||||
|
serverType: postgresType,
|
||||||
|
version: "11.16",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ex := range examples {
|
||||||
|
t.Run("input:"+ex.input, func(t *testing.T) {
|
||||||
|
match, stype, version := detectServerTypeAndVersion(ex.input)
|
||||||
|
|
||||||
|
assert.Equal(t, ex.match, match)
|
||||||
|
assert.Equal(t, ex.serverType, stype)
|
||||||
|
assert.Equal(t, ex.version, version)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectDumpVersion(t *testing.T) {
|
||||||
|
examples := []struct {
|
||||||
|
input string
|
||||||
|
match bool
|
||||||
|
version string
|
||||||
|
}{
|
||||||
|
{"", false, ""},
|
||||||
|
{"pg_dump (PostgreSQL) 9.6", true, "9.6"},
|
||||||
|
{"pg_dump 10", true, "10"},
|
||||||
|
{"pg_dump (PostgreSQL) 14.5 (Homebrew)", true, "14.5"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ex := range examples {
|
||||||
|
t.Run("input:"+ex.input, func(t *testing.T) {
|
||||||
|
match, version := detectDumpVersion(ex.input)
|
||||||
|
|
||||||
|
assert.Equal(t, ex.match, match)
|
||||||
|
assert.Equal(t, ex.version, version)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetMajorMinorVersion(t *testing.T) {
|
||||||
|
examples := []struct {
|
||||||
|
input string
|
||||||
|
major int
|
||||||
|
minor int
|
||||||
|
}{
|
||||||
|
{"", 0, 0},
|
||||||
|
{" ", 0, 0},
|
||||||
|
{"0", 0, 0},
|
||||||
|
{"9.6", 9, 6},
|
||||||
|
{"9.6.1.1", 9, 6},
|
||||||
|
{"10", 10, 0},
|
||||||
|
{"10.1 ", 10, 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ex := range examples {
|
||||||
|
t.Run(ex.input, func(t *testing.T) {
|
||||||
|
major, minor := getMajorMinorVersion(ex.input)
|
||||||
|
assert.Equal(t, ex.major, major)
|
||||||
|
assert.Equal(t, ex.minor, minor)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckVersionRequirement(t *testing.T) {
|
||||||
|
examples := []struct {
|
||||||
|
client string
|
||||||
|
server string
|
||||||
|
result bool
|
||||||
|
}{
|
||||||
|
{"", "", true},
|
||||||
|
{"0", "0", true},
|
||||||
|
{"9.6", "9.7", false},
|
||||||
|
{"9.6.10", "9.6.25", true},
|
||||||
|
{"10.0", "10.1", true},
|
||||||
|
{"10.5", "10.1", true},
|
||||||
|
{"14.5", "15.1", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ex := range examples {
|
||||||
|
assert.Equal(t, ex.result, checkVersionRequirement(ex.client, ex.server))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
package command
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/user"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jackc/pgpassfile"
|
||||||
|
"github.com/jessevdk/go-flags"
|
||||||
|
"github.com/mitchellh/go-homedir"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Prefix to use for all pgweb env vars, ie PGWEB_HOST, PGWEB_PORT, etc
|
||||||
|
envVarPrefix = "PGWEB_"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Options struct {
|
||||||
|
Version bool `short:"v" long:"version" description:"Print version"`
|
||||||
|
Debug bool `short:"d" long:"debug" description:"Enable debugging mode"`
|
||||||
|
LogLevel string `long:"log-level" description:"Logging level" default:"info"`
|
||||||
|
LogFormat string `long:"log-format" description:"Logging output format" default:"text"`
|
||||||
|
LogForwardedUser bool `long:"log-forwarded-user" description:"Log user information available in X-Forwarded-User/Email headers"`
|
||||||
|
URL string `long:"url" description:"Database connection string"`
|
||||||
|
Host string `long:"host" description:"Server hostname or IP" default:"localhost"`
|
||||||
|
Port int `long:"port" description:"Server port" default:"5432"`
|
||||||
|
User string `long:"user" description:"Database user"`
|
||||||
|
Pass string `long:"pass" description:"Password for user"`
|
||||||
|
Passfile string `long:"passfile" description:"Local passwords file location"`
|
||||||
|
DbName string `long:"db" description:"Database name"`
|
||||||
|
SSLMode string `long:"ssl" description:"SSL mode"`
|
||||||
|
SSLRootCert string `long:"ssl-rootcert" description:"SSL certificate authority file"`
|
||||||
|
SSLCert string `long:"ssl-cert" description:"SSL client certificate file"`
|
||||||
|
SSLKey string `long:"ssl-key" description:"SSL client certificate key file"`
|
||||||
|
OpenTimeout int `long:"open-timeout" description:" Maximum wait for connection, in seconds" default:"30"`
|
||||||
|
HTTPHost string `long:"bind" description:"HTTP server host" default:"localhost"`
|
||||||
|
HTTPPort uint `long:"listen" description:"HTTP server listen port" default:"8081"`
|
||||||
|
AuthUser string `long:"auth-user" description:"HTTP basic auth user"`
|
||||||
|
AuthPass string `long:"auth-pass" description:"HTTP basic auth password"`
|
||||||
|
SkipOpen bool `short:"s" long:"skip-open" description:"Skip browser open on start"`
|
||||||
|
Sessions bool `long:"sessions" description:"Enable multiple database sessions"`
|
||||||
|
Prefix string `long:"prefix" description:"Add a url prefix"`
|
||||||
|
ReadOnly bool `long:"readonly" description:"Run database connection in readonly mode"`
|
||||||
|
LockSession bool `long:"lock-session" description:"Lock session to a single database connection"`
|
||||||
|
Bookmark string `short:"b" long:"bookmark" description:"Bookmark to use for connection. Bookmark files are stored under $HOME/.pgweb/bookmarks/*.toml" default:""`
|
||||||
|
BookmarksDir string `long:"bookmarks-dir" description:"Overrides default directory for bookmark files to search" default:""`
|
||||||
|
QueriesDir string `long:"queries-dir" description:"Overrides default directory for local queries"`
|
||||||
|
DisablePrettyJSON bool `long:"no-pretty-json" description:"Disable JSON formatting feature for result export"`
|
||||||
|
DisableSSH bool `long:"no-ssh" description:"Disable database connections via SSH"`
|
||||||
|
ConnectBackend string `long:"connect-backend" description:"Enable database authentication through a third party backend"`
|
||||||
|
ConnectToken string `long:"connect-token" description:"Authentication token for the third-party connect backend"`
|
||||||
|
ConnectHeaders string `long:"connect-headers" description:"List of headers to pass to the connect backend"`
|
||||||
|
DisableConnectionIdleTimeout bool `long:"no-idle-timeout" description:"Disable connection idle timeout"`
|
||||||
|
ConnectionIdleTimeout int `long:"idle-timeout" description:"Set connection idle timeout in minutes" default:"180"`
|
||||||
|
QueryTimeout uint `long:"query-timeout" description:"Set global query execution timeout in seconds" default:"300"`
|
||||||
|
Cors bool `long:"cors" description:"Enable Cross-Origin Resource Sharing (CORS)"`
|
||||||
|
CorsOrigin string `long:"cors-origin" description:"Allowed CORS origins" default:"*"`
|
||||||
|
BinaryCodec string `long:"binary-codec" description:"Codec for binary data serialization, one of 'none', 'hex', 'base58', 'base64'" default:"none"`
|
||||||
|
MetricsEnabled bool `long:"metrics" description:"Enable Prometheus metrics endpoint"`
|
||||||
|
MetricsPath string `long:"metrics-path" description:"Path prefix for Prometheus metrics endpoint" default:"/metrics"`
|
||||||
|
MetricsAddr string `long:"metrics-addr" description:"Listen host and port for Prometheus metrics server"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var Opts Options
|
||||||
|
|
||||||
|
// ParseOptions returns a new options struct from the input arguments
|
||||||
|
func ParseOptions(args []string) (Options, error) {
|
||||||
|
var opts = Options{}
|
||||||
|
|
||||||
|
_, err := flags.ParseArgs(&opts, args)
|
||||||
|
if err != nil {
|
||||||
|
return opts, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = logrus.ParseLevel(opts.LogLevel)
|
||||||
|
if err != nil {
|
||||||
|
return opts, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.URL == "" {
|
||||||
|
opts.URL = getPrefixedEnvVar("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.Prefix == "" {
|
||||||
|
opts.Prefix = getPrefixedEnvVar("URL_PREFIX")
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.Passfile == "" {
|
||||||
|
passfile := os.Getenv("PGPASSFILE")
|
||||||
|
if passfile == "" {
|
||||||
|
passfile = filepath.Join(os.Getenv("HOME"), ".pgpass")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := os.Stat(passfile)
|
||||||
|
if err == nil {
|
||||||
|
_, err = pgpassfile.ReadPassfile(passfile)
|
||||||
|
if err == nil {
|
||||||
|
opts.Passfile = passfile
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[WARN] Pgpass file unreadable: %s\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle edge case where pgweb is started with a default host `localhost` and no user.
|
||||||
|
// When user is not set the `lib/pq` connection will fail and cause pgweb's termination.
|
||||||
|
if (opts.Host == "localhost" || opts.Host == "127.0.0.1") && opts.User == "" {
|
||||||
|
if username := getCurrentUser(); username != "" {
|
||||||
|
opts.User = username
|
||||||
|
} else {
|
||||||
|
opts.Host = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if getPrefixedEnvVar("SESSIONS") != "" {
|
||||||
|
opts.Sessions = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if getPrefixedEnvVar("LOCK_SESSION") != "" {
|
||||||
|
opts.LockSession = true
|
||||||
|
opts.Sessions = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.Sessions || opts.ConnectBackend != "" {
|
||||||
|
opts.Bookmark = ""
|
||||||
|
opts.URL = ""
|
||||||
|
opts.Host = ""
|
||||||
|
opts.User = ""
|
||||||
|
opts.Pass = ""
|
||||||
|
opts.DbName = ""
|
||||||
|
opts.SSLMode = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.Prefix != "" && !strings.Contains(opts.Prefix, "/") {
|
||||||
|
opts.Prefix = opts.Prefix + "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.AuthUser == "" {
|
||||||
|
opts.AuthUser = getPrefixedEnvVar("AUTH_USER")
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.AuthPass == "" {
|
||||||
|
opts.AuthPass = getPrefixedEnvVar("AUTH_PASS")
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.ConnectBackend != "" {
|
||||||
|
if !opts.Sessions {
|
||||||
|
return opts, errors.New("--sessions flag must be set")
|
||||||
|
}
|
||||||
|
if opts.ConnectToken == "" {
|
||||||
|
return opts, errors.New("--connect-token flag must be set")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if opts.ConnectToken != "" || opts.ConnectHeaders != "" {
|
||||||
|
return opts, errors.New("--connect-backend flag must be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
homePath, err := homedir.Dir()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "[WARN] cant detect home dir: %v", err)
|
||||||
|
homePath = os.Getenv("HOME")
|
||||||
|
}
|
||||||
|
|
||||||
|
if homePath != "" {
|
||||||
|
if opts.BookmarksDir == "" {
|
||||||
|
opts.BookmarksDir = filepath.Join(homePath, ".pgweb/bookmarks")
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.QueriesDir == "" {
|
||||||
|
opts.QueriesDir = filepath.Join(homePath, ".pgweb/queries")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return opts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDefaultOptions parses and assigns the options
|
||||||
|
func SetDefaultOptions() error {
|
||||||
|
opts, err := ParseOptions([]string{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
Opts = opts
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getCurrentUser returns a current user name
|
||||||
|
func getCurrentUser() string {
|
||||||
|
u, _ := user.Current()
|
||||||
|
if u != nil {
|
||||||
|
return u.Username
|
||||||
|
}
|
||||||
|
return os.Getenv("USER")
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPrefixedEnvVar returns env var with prefix, or falls back to unprefixed one
|
||||||
|
func getPrefixedEnvVar(name string) string {
|
||||||
|
val := os.Getenv(envVarPrefix + name)
|
||||||
|
if val == "" {
|
||||||
|
val = os.Getenv(name)
|
||||||
|
if val != "" {
|
||||||
|
fmt.Printf("[DEPRECATION] Usage of %s env var is deprecated, please use PGWEB_%s variable instead\n", name, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
// AvailableEnvVars returns list of supported env vars.
|
||||||
|
//
|
||||||
|
// TODO: These should probably be embedded into flag parsing logic so we dont have
|
||||||
|
// to maintain the list manually.
|
||||||
|
func AvailableEnvVars() string {
|
||||||
|
return strings.Join([]string{
|
||||||
|
" " + envVarPrefix + "DATABASE_URL Database connection string",
|
||||||
|
" " + envVarPrefix + "URL_PREFIX HTTP server path prefix",
|
||||||
|
" " + envVarPrefix + "SESSIONS: Enable multiple database sessions",
|
||||||
|
" " + envVarPrefix + "LOCK_SESSION Lock session to a single database connection",
|
||||||
|
" " + envVarPrefix + "AUTH_USER HTTP basic auth username",
|
||||||
|
" " + envVarPrefix + "AUTH_PASS HTTP basic auth password",
|
||||||
|
}, "\n")
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package command
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mitchellh/go-homedir"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseOptions(t *testing.T) {
|
||||||
|
var hdir string
|
||||||
|
if d, err := homedir.Dir(); err == nil {
|
||||||
|
hdir = d
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("defaults", func(t *testing.T) {
|
||||||
|
opts, err := ParseOptions([]string{})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, false, opts.Sessions)
|
||||||
|
assert.Equal(t, "", opts.Prefix)
|
||||||
|
assert.Equal(t, "", opts.ConnectToken)
|
||||||
|
assert.Equal(t, "", opts.ConnectHeaders)
|
||||||
|
assert.Equal(t, false, opts.DisableSSH)
|
||||||
|
assert.Equal(t, false, opts.DisablePrettyJSON)
|
||||||
|
assert.Equal(t, false, opts.DisableConnectionIdleTimeout)
|
||||||
|
assert.Equal(t, 180, opts.ConnectionIdleTimeout)
|
||||||
|
assert.Equal(t, false, opts.Cors)
|
||||||
|
assert.Equal(t, "*", opts.CorsOrigin)
|
||||||
|
assert.Equal(t, "", opts.Passfile)
|
||||||
|
assert.Equal(t, filepath.Join(hdir, ".pgweb/bookmarks"), opts.BookmarksDir)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("sessions", func(t *testing.T) {
|
||||||
|
opts, err := ParseOptions([]string{"--sessions", "1"})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, true, opts.Sessions)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("url prefix", func(t *testing.T) {
|
||||||
|
opts, err := ParseOptions([]string{"--prefix", "pgweb"})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "pgweb/", opts.Prefix)
|
||||||
|
|
||||||
|
opts, err = ParseOptions([]string{"--prefix", "pgweb/"})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "pgweb/", opts.Prefix)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("connect backend", func(t *testing.T) {
|
||||||
|
_, err := ParseOptions([]string{"--connect-backend", "test"})
|
||||||
|
assert.EqualError(t, err, "--sessions flag must be set")
|
||||||
|
|
||||||
|
_, err = ParseOptions([]string{"--connect-backend", "test", "--sessions"})
|
||||||
|
assert.EqualError(t, err, "--connect-token flag must be set")
|
||||||
|
|
||||||
|
_, err = ParseOptions([]string{"--connect-backend", "test", "--sessions", "--connect-token", "token"})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("passfile", func(t *testing.T) {
|
||||||
|
defer os.Unsetenv("PGPASSFILE")
|
||||||
|
|
||||||
|
// File does not exist
|
||||||
|
os.Setenv("PGPASSFILE", "/tmp/foo")
|
||||||
|
opts, err := ParseOptions([]string{})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "", opts.Passfile)
|
||||||
|
|
||||||
|
// File exists and valid
|
||||||
|
os.Setenv("PGPASSFILE", "../../data/passfile")
|
||||||
|
opts, err = ParseOptions([]string{})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "../../data/passfile", opts.Passfile)
|
||||||
|
|
||||||
|
// Set via flag
|
||||||
|
os.Unsetenv("PGPASSFILE")
|
||||||
|
opts, err = ParseOptions([]string{"--passfile", "../../data/passfile"})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "../../data/passfile", opts.Passfile)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package command
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Version is the current Pgweb application version
|
||||||
|
Version = "0.14.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// GitCommit contains the Git commit SHA for the binary
|
||||||
|
GitCommit string
|
||||||
|
|
||||||
|
// BuildTime contains the binary build time
|
||||||
|
BuildTime string
|
||||||
|
|
||||||
|
// BuildArch contains the OS architecture of the binary
|
||||||
|
BuildArch string = fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)
|
||||||
|
|
||||||
|
// GoVersion contains the build time Go version
|
||||||
|
GoVersion string
|
||||||
|
|
||||||
|
// Info contains all version information
|
||||||
|
Info VersionInfo
|
||||||
|
)
|
||||||
|
|
||||||
|
type VersionInfo struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
GitCommit string `json:"git_sha"`
|
||||||
|
BuildTime string `json:"build_time"`
|
||||||
|
BuildArch string `json:"build_arch"`
|
||||||
|
GoVersion string `json:"go_version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Info.Version = Version
|
||||||
|
Info.GitCommit = GitCommit
|
||||||
|
Info.BuildTime = BuildTime
|
||||||
|
Info.BuildArch = BuildArch
|
||||||
|
Info.GoVersion = GoVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
func VersionString() string {
|
||||||
|
chunks := []string{fmt.Sprintf("Pgweb v%s", Version)}
|
||||||
|
|
||||||
|
if GitCommit != "" {
|
||||||
|
chunks = append(chunks, fmt.Sprintf("(git: %s)", GitCommit))
|
||||||
|
}
|
||||||
|
if GoVersion != "" {
|
||||||
|
chunks = append(chunks, fmt.Sprintf("(go: %s)", GoVersion))
|
||||||
|
}
|
||||||
|
if BuildTime != "" {
|
||||||
|
chunks = append(chunks, fmt.Sprintf("(build time: %s)", BuildTime))
|
||||||
|
}
|
||||||
|
if BuildArch != "" {
|
||||||
|
chunks = append(chunks, fmt.Sprintf("(arch: %s)", BuildArch))
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(chunks, " ")
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package connection
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
neturl "net/url"
|
||||||
|
"os"
|
||||||
|
"os/user"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jackc/pgpassfile"
|
||||||
|
|
||||||
|
"github.com/sosedoff/pgweb/pkg/command"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Common errors
|
||||||
|
var (
|
||||||
|
errCantDetectUser = errors.New("Could not detect default username")
|
||||||
|
errInvalidURLFormat = errors.New("Invalid URL. Valid format: postgres://user:password@host:port/db?sslmode=mode")
|
||||||
|
)
|
||||||
|
|
||||||
|
// currentUser returns a current user name
|
||||||
|
func currentUser() (string, error) {
|
||||||
|
u, err := user.Current()
|
||||||
|
if err == nil {
|
||||||
|
return u.Username, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
name := os.Getenv("USER")
|
||||||
|
if name != "" {
|
||||||
|
return name, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", errCantDetectUser
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if connection url has a correct postgres prefix
|
||||||
|
func hasValidPrefix(str string) bool {
|
||||||
|
return strings.HasPrefix(str, "postgres://") || strings.HasPrefix(str, "postgresql://")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract all query vals and return as a map
|
||||||
|
func valsFromQuery(vals neturl.Values) map[string]string {
|
||||||
|
result := map[string]string{}
|
||||||
|
for k, v := range vals {
|
||||||
|
result[strings.ToLower(k)] = v[0]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatURL reformats the existing connection string
|
||||||
|
func FormatURL(opts command.Options) (string, error) {
|
||||||
|
url := opts.URL
|
||||||
|
|
||||||
|
// Validate connection string prefix
|
||||||
|
if !hasValidPrefix(url) {
|
||||||
|
return "", errInvalidURLFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the URL
|
||||||
|
uri, err := neturl.Parse(url)
|
||||||
|
if err != nil {
|
||||||
|
return "", errInvalidURLFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get query params
|
||||||
|
params := valsFromQuery(uri.Query())
|
||||||
|
|
||||||
|
// Determine if we need to specify sslmode if it's missing
|
||||||
|
if params["sslmode"] == "" {
|
||||||
|
if opts.SSLMode == "" {
|
||||||
|
// Only modify sslmode for local connections
|
||||||
|
if strings.Contains(uri.Host, "localhost") || strings.Contains(uri.Host, "127.0.0.1") {
|
||||||
|
params["sslmode"] = "disable"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
params["sslmode"] = opts.SSLMode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// When password is not provided, look it up from a .pgpass file
|
||||||
|
if uri.User != nil {
|
||||||
|
pass, _ := uri.User.Password()
|
||||||
|
if pass == "" && opts.Passfile != "" {
|
||||||
|
pass = lookupPassword(opts, uri)
|
||||||
|
if pass != "" {
|
||||||
|
uri.User = neturl.UserPassword(uri.User.Username(), pass)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure default connect timeout
|
||||||
|
if opts.OpenTimeout > 0 {
|
||||||
|
params["connect_timeout"] = strconv.Itoa(opts.OpenTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild query params
|
||||||
|
query := neturl.Values{}
|
||||||
|
for k, v := range params {
|
||||||
|
query.Add(k, v)
|
||||||
|
}
|
||||||
|
uri.RawQuery = query.Encode()
|
||||||
|
|
||||||
|
return uri.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsBlank returns true if command options do not contain connection details
|
||||||
|
func IsBlank(opts command.Options) bool {
|
||||||
|
return opts.Host == "" && opts.User == "" && opts.DbName == "" && opts.URL == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildStringFromOptions returns a new connection string built from options
|
||||||
|
func BuildStringFromOptions(opts command.Options) (string, error) {
|
||||||
|
query := neturl.Values{}
|
||||||
|
|
||||||
|
// If connection string is provided we just use that
|
||||||
|
if opts.URL != "" {
|
||||||
|
return FormatURL(opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to detect user from current OS user
|
||||||
|
if opts.User == "" {
|
||||||
|
u, err := currentUser()
|
||||||
|
if err == nil {
|
||||||
|
opts.User = u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.SSLMode != "" {
|
||||||
|
query.Add("sslmode", opts.SSLMode)
|
||||||
|
} else {
|
||||||
|
if opts.Host == "localhost" || opts.Host == "127.0.0.1" {
|
||||||
|
query.Add("sslmode", "disable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if opts.SSLCert != "" {
|
||||||
|
query.Add("sslcert", opts.SSLCert)
|
||||||
|
}
|
||||||
|
if opts.SSLKey != "" {
|
||||||
|
query.Add("sslkey", opts.SSLKey)
|
||||||
|
}
|
||||||
|
if opts.SSLRootCert != "" {
|
||||||
|
query.Add("sslrootcert", opts.SSLRootCert)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grab password from .pgpass file if it's available
|
||||||
|
if opts.Pass == "" && opts.Passfile != "" {
|
||||||
|
opts.Pass = lookupPassword(opts, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure default connect timeout
|
||||||
|
if opts.OpenTimeout > 0 {
|
||||||
|
query.Add("connect_timeout", strconv.Itoa(opts.OpenTimeout))
|
||||||
|
}
|
||||||
|
|
||||||
|
url := neturl.URL{
|
||||||
|
Scheme: "postgres",
|
||||||
|
Host: fmt.Sprintf("%v:%v", opts.Host, opts.Port),
|
||||||
|
User: neturl.UserPassword(opts.User, opts.Pass),
|
||||||
|
Path: fmt.Sprintf("/%s", opts.DbName),
|
||||||
|
RawQuery: query.Encode(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func lookupPassword(opts command.Options, url *neturl.URL) string {
|
||||||
|
if opts.Passfile == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
passfile, err := pgpassfile.ReadPassfile(opts.Passfile)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("[WARN] .pgpassfile", opts.Passfile, "is not readable")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if url != nil {
|
||||||
|
var dbName string
|
||||||
|
fmt.Sscanf(url.Path, "/%s", &dbName)
|
||||||
|
|
||||||
|
return passfile.FindPassword(
|
||||||
|
url.Hostname(),
|
||||||
|
url.Port(),
|
||||||
|
dbName,
|
||||||
|
url.User.Username(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return passfile.FindPassword(
|
||||||
|
opts.Host,
|
||||||
|
fmt.Sprintf("%d", opts.Port),
|
||||||
|
opts.DbName,
|
||||||
|
opts.User,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
package connection
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"os/user"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sosedoff/pgweb/pkg/command"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildStringFromOptions(t *testing.T) {
|
||||||
|
t.Run("valid url", func(t *testing.T) {
|
||||||
|
url := "postgres://myhost/database"
|
||||||
|
str, err := BuildStringFromOptions(command.Options{URL: url})
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, url, str)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("with sslmode param", func(t *testing.T) {
|
||||||
|
str, err := BuildStringFromOptions(command.Options{
|
||||||
|
URL: "postgres://myhost/database",
|
||||||
|
SSLMode: "disable",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "postgres://myhost/database?sslmode=disable", str)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("sets sslmode param if not set", func(t *testing.T) {
|
||||||
|
str, err := BuildStringFromOptions(command.Options{
|
||||||
|
URL: "postgres://localhost/database",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "postgres://localhost/database?sslmode=disable", str)
|
||||||
|
|
||||||
|
str, err = BuildStringFromOptions(command.Options{
|
||||||
|
URL: "postgres://127.0.0.1/database",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "postgres://127.0.0.1/database?sslmode=disable", str)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("sslmode as an option", func(t *testing.T) {
|
||||||
|
str, err := BuildStringFromOptions(command.Options{
|
||||||
|
URL: "postgres://localhost/database",
|
||||||
|
SSLMode: "require",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "postgres://localhost/database?sslmode=require", str)
|
||||||
|
|
||||||
|
str, err = BuildStringFromOptions(command.Options{
|
||||||
|
URL: "postgres://127.0.0.1/database",
|
||||||
|
SSLMode: "require",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "postgres://127.0.0.1/database?sslmode=require", str)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("localhost and sslmode flag", func(t *testing.T) {
|
||||||
|
str, err := BuildStringFromOptions(command.Options{
|
||||||
|
URL: "postgres://localhost/database?sslmode=require",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "postgres://localhost/database?sslmode=require", str)
|
||||||
|
|
||||||
|
str, err = BuildStringFromOptions(command.Options{
|
||||||
|
URL: "postgres://127.0.0.1/database?sslmode=require",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "postgres://127.0.0.1/database?sslmode=require", str)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("extended options", func(t *testing.T) {
|
||||||
|
str, err := BuildStringFromOptions(command.Options{
|
||||||
|
URL: "postgres://localhost/database?sslmode=require&sslcert=cert&sslkey=key&sslrootcert=ca",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "postgres://localhost/database?sslcert=cert&sslkey=key&sslmode=require&sslrootcert=ca", str)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("from flags", func(t *testing.T) {
|
||||||
|
str, err := BuildStringFromOptions(command.Options{
|
||||||
|
Host: "host",
|
||||||
|
Port: 5432,
|
||||||
|
User: "user",
|
||||||
|
Pass: "password",
|
||||||
|
DbName: "db",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "postgres://user:password@host:5432/db", str)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("localhost", func(t *testing.T) {
|
||||||
|
opts := command.Options{
|
||||||
|
Host: "localhost",
|
||||||
|
Port: 5432,
|
||||||
|
User: "user",
|
||||||
|
Pass: "password",
|
||||||
|
DbName: "db",
|
||||||
|
}
|
||||||
|
|
||||||
|
str, err := BuildStringFromOptions(opts)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "postgres://user:password@localhost:5432/db?sslmode=disable", str)
|
||||||
|
|
||||||
|
opts.Host = "127.0.0.1"
|
||||||
|
str, err = BuildStringFromOptions(opts)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "postgres://user:password@127.0.0.1:5432/db?sslmode=disable", str)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("localhost and ssl", func(t *testing.T) {
|
||||||
|
opts := command.Options{
|
||||||
|
Host: "localhost",
|
||||||
|
Port: 5432,
|
||||||
|
User: "user",
|
||||||
|
Pass: "password",
|
||||||
|
DbName: "db",
|
||||||
|
SSLMode: "require",
|
||||||
|
SSLKey: "keyPath",
|
||||||
|
SSLCert: "certPath",
|
||||||
|
SSLRootCert: "caPath",
|
||||||
|
}
|
||||||
|
|
||||||
|
str, err := BuildStringFromOptions(opts)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "postgres://user:password@localhost:5432/db?sslcert=certPath&sslkey=keyPath&sslmode=require&sslrootcert=caPath", str)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no user", func(t *testing.T) {
|
||||||
|
opts := command.Options{Host: "host", Port: 5432, DbName: "db"}
|
||||||
|
u, _ := user.Current()
|
||||||
|
str, err := BuildStringFromOptions(opts)
|
||||||
|
userAndPass := url.UserPassword(u.Username, "").String()
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, fmt.Sprintf("postgres://%s@host:5432/db", userAndPass), str)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("port", func(t *testing.T) {
|
||||||
|
opts := command.Options{Host: "host", User: "user", Port: 5000, DbName: "db"}
|
||||||
|
str, err := BuildStringFromOptions(opts)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "postgres://user:@host:5000/db", str)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("with pgpass", func(t *testing.T) {
|
||||||
|
opts := command.Options{
|
||||||
|
Host: "localhost",
|
||||||
|
Port: 5432,
|
||||||
|
User: "username",
|
||||||
|
DbName: "dbname",
|
||||||
|
Passfile: "../../data/passfile",
|
||||||
|
}
|
||||||
|
|
||||||
|
str, err := BuildStringFromOptions(opts)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "postgres://username:password@localhost:5432/dbname?sslmode=disable", str)
|
||||||
|
|
||||||
|
opts.User = "foobar"
|
||||||
|
str, err = BuildStringFromOptions(opts)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "postgres://foobar:@localhost:5432/dbname?sslmode=disable", str)
|
||||||
|
|
||||||
|
opts.Host = "127.0.0.1"
|
||||||
|
opts.DbName = "foobar2"
|
||||||
|
str, err = BuildStringFromOptions(opts)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "postgres://foobar:password2@127.0.0.1:5432/foobar2?sslmode=disable", str)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("with connection timeout", func(t *testing.T) {
|
||||||
|
opts := command.Options{
|
||||||
|
URL: "postgres://user:pass@localhost:5432/dbname",
|
||||||
|
OpenTimeout: 30,
|
||||||
|
}
|
||||||
|
str, err := BuildStringFromOptions(opts)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "postgres://user:pass@localhost:5432/dbname?connect_timeout=30&sslmode=disable", str)
|
||||||
|
|
||||||
|
opts = command.Options{
|
||||||
|
Host: "localhost",
|
||||||
|
Port: 5432,
|
||||||
|
User: "username",
|
||||||
|
DbName: "dbname",
|
||||||
|
OpenTimeout: 30,
|
||||||
|
}
|
||||||
|
|
||||||
|
str, err = BuildStringFromOptions(opts)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "postgres://username:@localhost:5432/dbname?connect_timeout=30&sslmode=disable", str)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid url", func(t *testing.T) {
|
||||||
|
opts := command.Options{}
|
||||||
|
examples := []string{
|
||||||
|
"postgre://foobar",
|
||||||
|
"tcp://blah",
|
||||||
|
"foobar",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, val := range examples {
|
||||||
|
opts.URL = val
|
||||||
|
str, err := BuildStringFromOptions(opts)
|
||||||
|
|
||||||
|
assert.Equal(t, "", str)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Equal(t, "Invalid URL. Valid format: postgres://user:password@host:port/db?sslmode=mode", err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatURL(t *testing.T) {
|
||||||
|
examples := []struct {
|
||||||
|
name string
|
||||||
|
input command.Options
|
||||||
|
result string
|
||||||
|
err string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty opts",
|
||||||
|
input: command.Options{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid url",
|
||||||
|
input: command.Options{URL: "barurl"},
|
||||||
|
err: "Invalid URL",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "good",
|
||||||
|
input: command.Options{
|
||||||
|
URL: "postgres://user:pass@localhost:5432/dbname",
|
||||||
|
},
|
||||||
|
result: "postgres://user:pass@localhost:5432/dbname?sslmode=disable",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "password lookup, password set",
|
||||||
|
input: command.Options{
|
||||||
|
URL: "postgres://username:@localhost:5432/dbname",
|
||||||
|
Passfile: "../../data/passfile",
|
||||||
|
},
|
||||||
|
result: "postgres://username:password@localhost:5432/dbname?sslmode=disable",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "password lookup, password not set",
|
||||||
|
input: command.Options{
|
||||||
|
URL: "postgres://username@localhost:5432/dbname",
|
||||||
|
Passfile: "../../data/passfile",
|
||||||
|
},
|
||||||
|
result: "postgres://username:password@localhost:5432/dbname?sslmode=disable",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with timeout setting",
|
||||||
|
input: command.Options{
|
||||||
|
URL: "postgres://username@localhost:5432/dbname",
|
||||||
|
OpenTimeout: 30,
|
||||||
|
},
|
||||||
|
result: "postgres://username@localhost:5432/dbname?connect_timeout=30&sslmode=disable",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ex := range examples {
|
||||||
|
t.Run(ex.name, func(t *testing.T) {
|
||||||
|
str, err := FormatURL(ex.input)
|
||||||
|
|
||||||
|
if ex.err != "" {
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), ex.err)
|
||||||
|
}
|
||||||
|
assert.Equal(t, ex.result, str)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsBlank(t *testing.T) {
|
||||||
|
assert.Equal(t, true, IsBlank(command.Options{}))
|
||||||
|
assert.Equal(t, false, IsBlank(command.Options{Host: "host", User: "user"}))
|
||||||
|
assert.Equal(t, false, IsBlank(command.Options{Host: "host", User: "user", DbName: "db"}))
|
||||||
|
assert.Equal(t, false, IsBlank(command.Options{URL: "url"}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package connection
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsPortAvailable returns true if there's no listeners on a given port
|
||||||
|
func IsPortAvailable(port int) bool {
|
||||||
|
conn, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%v", port))
|
||||||
|
if err != nil {
|
||||||
|
return strings.Index(err.Error(), "connection refused") > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.Close()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindAvailablePort returns the first available TCP port in the range
|
||||||
|
func FindAvailablePort(start int, limit int) (int, error) {
|
||||||
|
for i := start; i <= (start + limit); i++ {
|
||||||
|
if IsPortAvailable(i) {
|
||||||
|
return i, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1, errors.New("No available port")
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package connection
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsPortAvailable(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("FIXME")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, true, IsPortAvailable(30000))
|
||||||
|
|
||||||
|
serv, err := net.Listen("tcp", "127.0.0.1:30000")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "Unable to start test tcp listener:", err)
|
||||||
|
t.Fail()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer serv.Close()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
conn, err := serv.Accept()
|
||||||
|
if err == nil {
|
||||||
|
conn.Close()
|
||||||
|
}
|
||||||
|
serv.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
assert.Equal(t, false, IsPortAvailable(30000))
|
||||||
|
assert.Equal(t, true, IsPortAvailable(30001))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindAvailablePort(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("FIXME")
|
||||||
|
}
|
||||||
|
|
||||||
|
port, err := FindAvailablePort(30000, 1)
|
||||||
|
assert.Equal(t, nil, err)
|
||||||
|
assert.Equal(t, 30000, port)
|
||||||
|
|
||||||
|
serv, err := net.Listen("tcp", "127.0.0.1:30000")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "Unable to start test tcp listener:", err)
|
||||||
|
t.Fail()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer serv.Close()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
conn, err := serv.Accept()
|
||||||
|
if err == nil {
|
||||||
|
conn.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
port, err = FindAvailablePort(30000, 0)
|
||||||
|
assert.EqualError(t, err, "No available port")
|
||||||
|
assert.Equal(t, -1, port)
|
||||||
|
|
||||||
|
port, err = FindAvailablePort(30000, 1)
|
||||||
|
assert.Equal(t, nil, err)
|
||||||
|
assert.Equal(t, 30001, port)
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package history
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Record struct {
|
||||||
|
Query string `json:"query"`
|
||||||
|
Timestamp string `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() []Record {
|
||||||
|
return make([]Record, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRecord(query string) Record {
|
||||||
|
return Record{
|
||||||
|
Query: query,
|
||||||
|
Timestamp: time.Now().String(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
sessionsGauge = promauto.NewGauge(prometheus.GaugeOpts{
|
||||||
|
Name: "pgweb_sessions_count",
|
||||||
|
Help: "Total number of database sessions",
|
||||||
|
})
|
||||||
|
|
||||||
|
queriesCounter = promauto.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: "pgweb_queries_count",
|
||||||
|
Help: "Total number of custom queries executed",
|
||||||
|
})
|
||||||
|
|
||||||
|
healtyGauge = promauto.NewGauge(prometheus.GaugeOpts{
|
||||||
|
Name: "pgweb_healty",
|
||||||
|
Help: "Server health status",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
func IncrementQueriesCount() {
|
||||||
|
queriesCounter.Inc()
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetSessionsCount(val int) {
|
||||||
|
sessionsGauge.Set(float64(val))
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetHealty(val bool) {
|
||||||
|
healthy := 0.0
|
||||||
|
if val {
|
||||||
|
healthy = 1.0
|
||||||
|
}
|
||||||
|
healtyGauge.Set(float64(healthy))
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Handler() http.Handler {
|
||||||
|
return promhttp.Handler()
|
||||||
|
}
|
||||||
|
|
||||||
|
func StartServer(logger *logrus.Logger, path string, addr string) error {
|
||||||
|
logger.WithField("addr", addr).WithField("path", path).Info("starting prometheus metrics server")
|
||||||
|
|
||||||
|
http.Handle(path, Handler())
|
||||||
|
return http.ListenAndServe(addr, nil)
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package queries
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type field struct {
|
||||||
|
value string
|
||||||
|
re *regexp.Regexp
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f field) String() string {
|
||||||
|
return f.value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f field) matches(input string) bool {
|
||||||
|
if f.re != nil {
|
||||||
|
return f.re.MatchString(input)
|
||||||
|
}
|
||||||
|
return f.value == input
|
||||||
|
}
|
||||||
|
|
||||||
|
func newField(value string) (field, error) {
|
||||||
|
f := field{value: value}
|
||||||
|
|
||||||
|
if value == "*" { // match everything
|
||||||
|
f.re = reMatchAll
|
||||||
|
} else if reExpression.MatchString(value) { // match by given expression
|
||||||
|
// Make writing expressions easier for values like "foo_*"
|
||||||
|
if strings.Count(value, "*") == 1 {
|
||||||
|
value = strings.Replace(value, "*", "(.+)", 1)
|
||||||
|
}
|
||||||
|
re, err := regexp.Compile(fmt.Sprintf("^%s$", value))
|
||||||
|
if err != nil {
|
||||||
|
return f, err
|
||||||
|
}
|
||||||
|
f.re = re
|
||||||
|
}
|
||||||
|
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package queries
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_field(t *testing.T) {
|
||||||
|
field, err := newField("val")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "val", field.value)
|
||||||
|
assert.Equal(t, true, field.matches("val"))
|
||||||
|
assert.Equal(t, false, field.matches("value"))
|
||||||
|
|
||||||
|
field, err = newField("*")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "*", field.value)
|
||||||
|
assert.NotNil(t, field.re)
|
||||||
|
assert.Equal(t, true, field.matches("val"))
|
||||||
|
assert.Equal(t, true, field.matches("value"))
|
||||||
|
|
||||||
|
field, err = newField("(.+")
|
||||||
|
assert.EqualError(t, err, "error parsing regexp: missing closing ): `^(.+$`")
|
||||||
|
assert.NotNil(t, field)
|
||||||
|
|
||||||
|
field, err = newField("foo_*")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "foo_*", field.value)
|
||||||
|
assert.NotNil(t, field.re)
|
||||||
|
assert.Equal(t, false, field.matches("foo"))
|
||||||
|
assert.Equal(t, true, field.matches("foo_bar"))
|
||||||
|
assert.Equal(t, true, field.matches("foo_bar_widget"))
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_fieldString(t *testing.T) {
|
||||||
|
field, err := newField("val")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "val", field.String())
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package queries
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
reMetaPrefix = regexp.MustCompile(`(?m)^\s*--\s*pgweb:\s*(.+)`)
|
||||||
|
reMetaContent = regexp.MustCompile(`([\w]+)\s*=\s*"([^"]+)"`)
|
||||||
|
reMatchAll = regexp.MustCompile(`^(.+)$`)
|
||||||
|
reExpression = regexp.MustCompile(`[\[\]\(\)\+\*]+`)
|
||||||
|
|
||||||
|
allowedKeys = []string{"title", "description", "host", "user", "database", "mode", "timeout"}
|
||||||
|
allowedModes = map[string]bool{"readonly": true, "*": true}
|
||||||
|
)
|
||||||
|
|
||||||
|
type Metadata struct {
|
||||||
|
Title string
|
||||||
|
Description string
|
||||||
|
Host field
|
||||||
|
User field
|
||||||
|
Database field
|
||||||
|
Mode field
|
||||||
|
Timeout *time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMetadata(input string) (*Metadata, error) {
|
||||||
|
fields, err := parseFields(input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if fields == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Host must be set to limit queries availability
|
||||||
|
if fields["host"] == "" {
|
||||||
|
return nil, fmt.Errorf("host field must be set")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow matching for any user, database and mode by default
|
||||||
|
if fields["user"] == "" {
|
||||||
|
fields["user"] = "*"
|
||||||
|
}
|
||||||
|
if fields["database"] == "" {
|
||||||
|
fields["database"] = "*"
|
||||||
|
}
|
||||||
|
if fields["mode"] == "" {
|
||||||
|
fields["mode"] = "*"
|
||||||
|
}
|
||||||
|
|
||||||
|
hostField, err := newField(fields["host"])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(`error initializing "host" field: %w`, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
userField, err := newField(fields["user"])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(`error initializing "user" field: %w`, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dbField, err := newField(fields["database"])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(`error initializing "database" field: %w`, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !allowedModes[fields["mode"]] {
|
||||||
|
return nil, fmt.Errorf(`invalid "mode" field value: %q`, fields["mode"])
|
||||||
|
}
|
||||||
|
modeField, err := newField(fields["mode"])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(`error initializing "mode" field: %w`, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var timeout *time.Duration
|
||||||
|
if fields["timeout"] != "" {
|
||||||
|
timeoutSec, err := strconv.Atoi(fields["timeout"])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(`error initializing "timeout" field: %w`, err)
|
||||||
|
}
|
||||||
|
timeoutVal := time.Duration(timeoutSec) * time.Second
|
||||||
|
timeout = &timeoutVal
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Metadata{
|
||||||
|
Title: fields["title"],
|
||||||
|
Description: fields["description"],
|
||||||
|
Host: hostField,
|
||||||
|
User: userField,
|
||||||
|
Database: dbField,
|
||||||
|
Mode: modeField,
|
||||||
|
Timeout: timeout,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFields(input string) (map[string]string, error) {
|
||||||
|
result := map[string]string{}
|
||||||
|
seenKeys := map[string]bool{}
|
||||||
|
|
||||||
|
allowed := map[string]bool{}
|
||||||
|
for _, key := range allowedKeys {
|
||||||
|
allowed[key] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
matches := reMetaPrefix.FindAllStringSubmatch(input, -1)
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, match := range matches {
|
||||||
|
content := reMetaContent.FindAllStringSubmatch(match[1], -1)
|
||||||
|
if len(content) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, field := range content {
|
||||||
|
key := field[1]
|
||||||
|
value := field[2]
|
||||||
|
|
||||||
|
if !allowed[key] {
|
||||||
|
return result, fmt.Errorf("unknown key: %q", key)
|
||||||
|
}
|
||||||
|
if seenKeys[key] {
|
||||||
|
return result, fmt.Errorf("duplicate key: %q", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
seenKeys[key] = true
|
||||||
|
result[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeMetadata(input string) string {
|
||||||
|
lines := []string{}
|
||||||
|
for _, line := range strings.Split(input, "\n") {
|
||||||
|
line = reMetaPrefix.ReplaceAllString(line, "")
|
||||||
|
if len(line) > 0 {
|
||||||
|
lines = append(lines, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package queries
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_parseFields(t *testing.T) {
|
||||||
|
examples := []struct {
|
||||||
|
input string
|
||||||
|
err error
|
||||||
|
vals map[string]string
|
||||||
|
}{
|
||||||
|
{input: "", err: nil, vals: nil},
|
||||||
|
{input: "foobar", err: nil, vals: nil},
|
||||||
|
{input: "-- no pgweb meta", err: nil, vals: nil},
|
||||||
|
{
|
||||||
|
input: `--pgweb: foo=bar`,
|
||||||
|
err: nil,
|
||||||
|
vals: map[string]string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: `--pgweb: host="localhost"`,
|
||||||
|
err: nil,
|
||||||
|
vals: map[string]string{"host": "localhost"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: `--pgweb: host="*" user="admin" database ="mydb"; mode = "readonly"`,
|
||||||
|
err: nil,
|
||||||
|
vals: map[string]string{
|
||||||
|
"host": "*",
|
||||||
|
"database": "mydb",
|
||||||
|
"user": "admin",
|
||||||
|
"mode": "readonly",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ex := range examples {
|
||||||
|
t.Run(ex.input, func(t *testing.T) {
|
||||||
|
fields, err := parseFields(ex.input)
|
||||||
|
assert.Equal(t, ex.err, err)
|
||||||
|
assert.Equal(t, ex.vals, fields)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_parseMetadata(t *testing.T) {
|
||||||
|
examples := []struct {
|
||||||
|
input string
|
||||||
|
err string
|
||||||
|
check func(meta *Metadata) bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
input: `--pgweb: `,
|
||||||
|
err: `host field must be set`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: `--pgweb: hello="world"`,
|
||||||
|
err: `unknown key: "hello"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: `--pgweb: host="localhost" user="anyuser" database="anydb" mode="foo"`,
|
||||||
|
err: `invalid "mode" field value: "foo"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "--pgweb2:",
|
||||||
|
check: func(m *Metadata) bool {
|
||||||
|
return m == nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: `--pgweb: host="localhost"`,
|
||||||
|
check: func(m *Metadata) bool {
|
||||||
|
return m.Host.value == "localhost" &&
|
||||||
|
m.User.value == "*" &&
|
||||||
|
m.Database.value == "*" &&
|
||||||
|
m.Mode.value == "*" &&
|
||||||
|
m.Timeout == nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: `--pgweb: host="localhost" user="anyuser" database="anydb" mode="*"`,
|
||||||
|
check: func(m *Metadata) bool {
|
||||||
|
return m.Host.value == "localhost" &&
|
||||||
|
m.Host.re == nil &&
|
||||||
|
m.User.value == "anyuser" &&
|
||||||
|
m.Database.value == "anydb" &&
|
||||||
|
m.Mode.value == "*" &&
|
||||||
|
m.Timeout == nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: `--pgweb: host="localhost" timeout="foo"`,
|
||||||
|
err: `error initializing "timeout" field: strconv.Atoi: parsing "foo": invalid syntax`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: `-- pgweb: host="local(host|dev)"`,
|
||||||
|
check: func(m *Metadata) bool {
|
||||||
|
return m.Host.value == "local(host|dev)" && m.Host.re != nil &&
|
||||||
|
m.Host.matches("localhost") && m.Host.matches("localdev") &&
|
||||||
|
!m.Host.matches("localfoo") && !m.Host.matches("superlocaldev")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ex := range examples {
|
||||||
|
t.Run(ex.input, func(t *testing.T) {
|
||||||
|
meta, err := parseMetadata(ex.input)
|
||||||
|
if ex.err != "" {
|
||||||
|
assert.Contains(t, err.Error(), ex.err)
|
||||||
|
}
|
||||||
|
if ex.check != nil {
|
||||||
|
assert.Equal(t, true, ex.check(meta))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_sanitizeMetadata(t *testing.T) {
|
||||||
|
examples := []struct {
|
||||||
|
input string
|
||||||
|
output string
|
||||||
|
}{
|
||||||
|
{input: "", output: ""},
|
||||||
|
{input: "foo", output: "foo"},
|
||||||
|
{
|
||||||
|
input: `
|
||||||
|
-- pgweb: metadata
|
||||||
|
query1
|
||||||
|
-- pgweb: more metadata
|
||||||
|
|
||||||
|
query2
|
||||||
|
|
||||||
|
`,
|
||||||
|
output: "query1\nquery2",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ex := range examples {
|
||||||
|
t.Run(ex.input, func(t *testing.T) {
|
||||||
|
assert.Equal(t, ex.output, sanitizeMetadata(ex.input))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package queries
|
||||||
|
|
||||||
|
type Query struct {
|
||||||
|
ID string
|
||||||
|
Path string
|
||||||
|
Meta *Metadata
|
||||||
|
Data string
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsPermitted returns true if a query is allowed to execute for a given db context
|
||||||
|
func (q Query) IsPermitted(host, user, database, mode string) bool {
|
||||||
|
// All fields must be provided for matching
|
||||||
|
if q.Meta == nil || host == "" || user == "" || database == "" || mode == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
meta := q.Meta
|
||||||
|
|
||||||
|
return meta.Host.matches(host) &&
|
||||||
|
meta.User.matches(user) &&
|
||||||
|
meta.Database.matches(database) &&
|
||||||
|
meta.Mode.matches(mode)
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package queries
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestQueryIsPermitted(t *testing.T) {
|
||||||
|
examples := []struct {
|
||||||
|
name string
|
||||||
|
query Query
|
||||||
|
args []string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no input provided",
|
||||||
|
query: makeQuery("localhost", "someuser", "somedb", "default"),
|
||||||
|
args: makeArgs("", "", "", ""),
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "match on host",
|
||||||
|
query: makeQuery("localhost", "*", "*", "*"),
|
||||||
|
args: makeArgs("localhost", "user", "db", "default"),
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "match on full set",
|
||||||
|
query: makeQuery("localhost", "user", "database", "mode"),
|
||||||
|
args: makeArgs("localhost", "someuser", "somedb", "default"),
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "match on partial database",
|
||||||
|
query: makeQuery("localhost", "*", "myapp_*", "*"),
|
||||||
|
args: makeArgs("localhost", "user", "myapp_development", "default"),
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "match on full set but not mode",
|
||||||
|
query: makeQuery("localhost", "*", "*", "readonly"),
|
||||||
|
args: makeArgs("localhost", "user", "db", "default"),
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ex := range examples {
|
||||||
|
t.Run(ex.name, func(t *testing.T) {
|
||||||
|
result := ex.query.IsPermitted(ex.args[0], ex.args[1], ex.args[2], ex.args[3])
|
||||||
|
assert.Equal(t, ex.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeArgs(vals ...string) []string {
|
||||||
|
return vals
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeQuery(host, user, database, mode string) Query {
|
||||||
|
mustfield := func(input string) field {
|
||||||
|
f, err := newField(input)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
return Query{
|
||||||
|
Meta: &Metadata{
|
||||||
|
Host: mustfield(host),
|
||||||
|
User: mustfield(user),
|
||||||
|
Database: mustfield(database),
|
||||||
|
Mode: mustfield(mode),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package queries
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrQueryDirNotExist = errors.New("queries directory does not exist")
|
||||||
|
ErrQueryFileNotExist = errors.New("query file does not exist")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
dir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore(dir string) *Store {
|
||||||
|
return &Store{
|
||||||
|
dir: dir,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Store) Read(id string) (*Query, error) {
|
||||||
|
path := filepath.Join(s.dir, fmt.Sprintf("%s.sql", id))
|
||||||
|
return readQuery(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Store) ReadAll() ([]Query, error) {
|
||||||
|
entries, err := os.ReadDir(s.dir)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
err = ErrQueryDirNotExist
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
queries := []Query{}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
name := entry.Name()
|
||||||
|
if filepath.Ext(name) != ".sql" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(s.dir, name)
|
||||||
|
query, err := readQuery(path)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "[WARN] skipping %q query file due to error: %v\n", name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if query == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
queries = append(queries, *query)
|
||||||
|
}
|
||||||
|
|
||||||
|
return queries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readQuery(path string) (*Query, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, ErrQueryFileNotExist
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dataStr := string(data)
|
||||||
|
|
||||||
|
meta, err := parseMetadata(dataStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if meta == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Query{
|
||||||
|
ID: strings.Replace(filepath.Base(path), ".sql", "", 1),
|
||||||
|
Path: path,
|
||||||
|
Meta: meta,
|
||||||
|
Data: sanitizeMetadata(dataStr),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package queries
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStoreReadAll(t *testing.T) {
|
||||||
|
t.Run("valid dir", func(t *testing.T) {
|
||||||
|
queries, err := NewStore("../../data").ReadAll()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 2, len(queries))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid dir", func(t *testing.T) {
|
||||||
|
queries, err := NewStore("../../data2").ReadAll()
|
||||||
|
assert.Equal(t, err.Error(), "queries directory does not exist")
|
||||||
|
assert.Equal(t, 0, len(queries))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreRead(t *testing.T) {
|
||||||
|
examples := []struct {
|
||||||
|
id string
|
||||||
|
err string
|
||||||
|
check func(*testing.T, *Query)
|
||||||
|
}{
|
||||||
|
{id: "foo", err: "query file does not exist"},
|
||||||
|
{id: "lc_no_meta"},
|
||||||
|
{id: "lc_invalid_meta", err: `invalid "mode" field value: "foo"`},
|
||||||
|
{
|
||||||
|
id: "lc_example1",
|
||||||
|
check: func(t *testing.T, q *Query) {
|
||||||
|
assert.Equal(t, "lc_example1", q.ID)
|
||||||
|
assert.Equal(t, "../../data/lc_example1.sql", q.Path)
|
||||||
|
assert.Equal(t, "select 'foo'", q.Data)
|
||||||
|
assert.Equal(t, "localhost", q.Meta.Host.String())
|
||||||
|
assert.Equal(t, "*", q.Meta.User.String())
|
||||||
|
assert.Equal(t, "*", q.Meta.Database.String())
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "lc_example2",
|
||||||
|
check: func(t *testing.T, q *Query) {
|
||||||
|
assert.Equal(t, "lc_example2", q.ID)
|
||||||
|
assert.Equal(t, "../../data/lc_example2.sql", q.Path)
|
||||||
|
assert.Equal(t, "-- some comment\nselect 'foo'", q.Data)
|
||||||
|
assert.Equal(t, "localhost", q.Meta.Host.String())
|
||||||
|
assert.Equal(t, "foo", q.Meta.User.String())
|
||||||
|
assert.Equal(t, "*", q.Meta.Database.String())
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
store := NewStore("../../data")
|
||||||
|
|
||||||
|
for _, ex := range examples {
|
||||||
|
t.Run(ex.id, func(t *testing.T) {
|
||||||
|
query, err := store.Read(ex.id)
|
||||||
|
if ex.err != "" || err != nil {
|
||||||
|
assert.Equal(t, ex.err, err.Error())
|
||||||
|
}
|
||||||
|
if ex.check != nil {
|
||||||
|
ex.check(t, query)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package shared
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SSHInfo contains ssh server configuration
|
||||||
|
type SSHInfo struct {
|
||||||
|
Host string `json:"host,omitempty"`
|
||||||
|
Port string `json:"port,omitempty"`
|
||||||
|
User string `json:"user,omitempty"`
|
||||||
|
Password string `json:"password,omitempty"`
|
||||||
|
Key string `json:"key,omitempty"`
|
||||||
|
KeyPassword string `json:"keypassword,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (info SSHInfo) String() string {
|
||||||
|
return fmt.Sprintf("%s@%s:%s", info.User, info.Host, info.Port)
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package statements
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
//go:embed sql/databases.sql
|
||||||
|
Databases string
|
||||||
|
|
||||||
|
//go:embed sql/schemas.sql
|
||||||
|
Schemas string
|
||||||
|
|
||||||
|
//go:embed sql/info.sql
|
||||||
|
Info string
|
||||||
|
|
||||||
|
//go:embed sql/info_simple.sql
|
||||||
|
InfoSimple string
|
||||||
|
|
||||||
|
//go:embed sql/estimated_row_count.sql
|
||||||
|
EstimatedTableRowCount string
|
||||||
|
|
||||||
|
//go:embed sql/table_indexes.sql
|
||||||
|
TableIndexes string
|
||||||
|
|
||||||
|
//go:embed sql/table_constraints.sql
|
||||||
|
TableConstraints string
|
||||||
|
|
||||||
|
//go:embed sql/table_info.sql
|
||||||
|
TableInfo string
|
||||||
|
|
||||||
|
//go:embed sql/table_info_cockroach.sql
|
||||||
|
TableInfoCockroach string
|
||||||
|
|
||||||
|
//go:embed sql/table_schema.sql
|
||||||
|
TableSchema string
|
||||||
|
|
||||||
|
//go:embed sql/materialized_view.sql
|
||||||
|
MaterializedView string
|
||||||
|
|
||||||
|
//go:embed sql/objects.sql
|
||||||
|
Objects string
|
||||||
|
|
||||||
|
//go:embed sql/tables_stats.sql
|
||||||
|
TablesStats string
|
||||||
|
|
||||||
|
//go:embed sql/function.sql
|
||||||
|
Function string
|
||||||
|
|
||||||
|
// Activity queries for specific PG versions
|
||||||
|
Activity = map[string]string{
|
||||||
|
"default": "SELECT * FROM pg_stat_activity WHERE datname = current_database()",
|
||||||
|
"9.1": "SELECT datname, current_query, waiting, query_start, procpid as pid, datid, application_name, client_addr FROM pg_stat_activity WHERE datname = current_database()",
|
||||||
|
"9.2": "SELECT datname, query, state, waiting, query_start, state_change, pid, datid, application_name, client_addr FROM pg_stat_activity WHERE datname = current_database()",
|
||||||
|
"9.3": "SELECT datname, query, state, waiting, query_start, state_change, pid, datid, application_name, client_addr FROM pg_stat_activity WHERE datname = current_database()",
|
||||||
|
"9.4": "SELECT datname, query, state, waiting, query_start, state_change, pid, datid, application_name, client_addr FROM pg_stat_activity WHERE datname = current_database()",
|
||||||
|
"9.5": "SELECT datname, query, state, waiting, query_start, state_change, pid, datid, application_name, client_addr FROM pg_stat_activity WHERE datname = current_database()",
|
||||||
|
"9.6": "SELECT datname, query, state, wait_event, wait_event_type, query_start, state_change, pid, datid, application_name, client_addr FROM pg_stat_activity WHERE datname = current_database()",
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
SELECT
|
||||||
|
datname
|
||||||
|
FROM
|
||||||
|
pg_database
|
||||||
|
WHERE
|
||||||
|
NOT datistemplate
|
||||||
|
ORDER BY
|
||||||
|
datname ASC
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
SELECT
|
||||||
|
reltuples
|
||||||
|
FROM
|
||||||
|
pg_class
|
||||||
|
WHERE
|
||||||
|
oid = ('"' || $1::text || '"."' || $2::text || '"')::regclass
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user