Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3312c357f3 | ||
|
|
fb6091bb83 | ||
|
|
16f88a1336 | ||
|
|
e0fcd058df | ||
|
|
9e37bd27f1 | ||
|
|
6b0b0244d1 | ||
|
|
c6ece21133 | ||
|
|
4919407228 | ||
|
|
1c3c82de18 | ||
|
|
06d73805a8 | ||
|
|
74e8482f96 | ||
|
|
79d8afc715 | ||
|
|
9a5df8487a | ||
|
|
3fb87562ed | ||
|
|
6a52fea44a | ||
|
|
e3085a44ba | ||
|
|
4ce11c039e | ||
|
|
70f62feec8 | ||
|
|
266a516076 | ||
|
|
2e8800150e | ||
|
|
8413681db0 | ||
|
|
ed8a581ff2 | ||
|
|
302aa1d85b | ||
|
|
f5f78eb32e | ||
|
|
e6df3d19fa | ||
|
|
08b1ea71e7 | ||
|
|
7fe302ed7c | ||
|
|
bcd170ab74 | ||
|
|
e63945ee71 | ||
|
|
487e2820f4 | ||
|
|
a716093236 | ||
|
|
f8bfe2de56 | ||
|
|
136b2a6942 | ||
|
|
c2c69e032c | ||
|
|
3ff70b916f | ||
|
|
22eb352180 | ||
|
|
3a32f531a0 | ||
|
|
63f1150056 | ||
|
|
40f582d1ea | ||
|
|
cf86131808 | ||
|
|
f3d6d9d73e | ||
|
|
1b03d78800 | ||
|
|
6489f6a800 | ||
|
|
36436f3aa7 | ||
|
|
db05989d07 | ||
|
|
3b2bec68ef | ||
|
|
9717d3a132 | ||
|
|
f4e7643e22 | ||
|
|
605c483d5b | ||
|
|
8a8b9f07fb | ||
|
|
7ee3c61e38 | ||
|
|
408e23adb3 | ||
|
|
e560f07de6 | ||
|
|
fe5039d17a | ||
|
|
f810c0227b | ||
|
|
10c90bcd01 | ||
|
|
f3a6ce4636 | ||
|
|
87030c8a17 | ||
|
|
614700e69a | ||
|
|
5371b4af56 | ||
|
|
22daaad5a2 | ||
|
|
14d8d80b86 | ||
|
|
e0c2099e91 | ||
|
|
a4185415e2 | ||
|
|
189ca505bc | ||
|
|
0309d88298 | ||
|
|
60532e852b | ||
|
|
d4cfb059ce | ||
|
|
52f7988ebd | ||
|
|
b2067fbc8d | ||
|
|
f037a4b0d2 | ||
|
|
4d32e3b2c5 | ||
|
|
f94bc9a4dd | ||
|
|
e905e5de53 | ||
|
|
08bbb1537e | ||
|
|
941c0acea3 | ||
|
|
bed7ab9564 | ||
|
|
d5e72f92b5 | ||
|
|
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 |
@@ -0,0 +1,3 @@
|
||||
.github
|
||||
bin/
|
||||
./pgweb
|
||||
@@ -0,0 +1,113 @@
|
||||
name: checks
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
|
||||
|
||||
env:
|
||||
GO_VERSION: "1.25"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
name: tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 40
|
||||
strategy:
|
||||
matrix:
|
||||
pg_version: [9.6, 10, 11, 12, 13, 14, 15, 16, 17, 18]
|
||||
|
||||
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-18
|
||||
|
||||
- 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@v8
|
||||
with:
|
||||
version: v2.7.2
|
||||
|
||||
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:
|
||||
- main
|
||||
|
||||
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,37 @@
|
||||
name: docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
GO_VERSION: "1.25"
|
||||
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/v7
|
||||
build-args: |
|
||||
"CGO_ENABLED=${{ env.CGO_ENABLED }}"
|
||||
@@ -0,0 +1,60 @@
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
env:
|
||||
GO_VERSION: "1.25"
|
||||
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/v7
|
||||
@@ -1,4 +1,7 @@
|
||||
.DS_Store
|
||||
.idea
|
||||
.env
|
||||
.envrc
|
||||
pgweb
|
||||
bin
|
||||
tmp/
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
version: "2"
|
||||
linters:
|
||||
disable:
|
||||
- errcheck
|
||||
settings:
|
||||
staticcheck:
|
||||
checks: ["all", "-ST1000", "-ST1003", "-ST1016", "-ST1020", "-ST1021", "-ST1022", "-ST1005", "-QF1004"]
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
sudo: required
|
||||
|
||||
language: go
|
||||
|
||||
services:
|
||||
- docker
|
||||
|
||||
addons:
|
||||
postgresql: "9.6"
|
||||
|
||||
go:
|
||||
- 1.5.4
|
||||
- 1.6.4
|
||||
- 1.7.6
|
||||
- 1.8.3
|
||||
|
||||
env:
|
||||
- GO15VENDOREXPERIMENT=1
|
||||
|
||||
install:
|
||||
- make setup
|
||||
|
||||
script:
|
||||
- make build
|
||||
- make test
|
||||
- make test-all
|
||||
- make docker-release
|
||||
+285
-2
@@ -1,3 +1,286 @@
|
||||
## Changelog
|
||||
|
||||
Current [release](https://github.com/sosedoff/pgweb/releases) is `0.17.0`.
|
||||
|
||||
## 0.17.0 - 2025-11-22
|
||||
|
||||
- `NEW` Update Dockerfile to use Golang 1.24 image, GH-821
|
||||
- `NEW` Bump go crypto package to 0.44.x, GH-820, GH-823
|
||||
- `NEW` Add PostgreSQL 18 support to Github Actions, GH-816
|
||||
- `NEW` Connect backend refactor, GH-801
|
||||
- `NEW` Add server settings view, GH-768
|
||||
- `NEW` Add exec time of empty queries, GH-763
|
||||
- `FIX` Minor typos, GH-764, GH-786
|
||||
- `FIX` Shorten git revision printed in the --version output, GH-770
|
||||
|
||||
## 0.16.2 - 2024-11-02
|
||||
|
||||
- `FIX` Build a new Docker image with PostgreSQL 17 support
|
||||
- `FIX` Run CI against PostgreSQL 17, GH-758
|
||||
- `FIX` Rename master to main branch, GH-750
|
||||
|
||||
## 0.16.1 - 2024-09-07
|
||||
|
||||
- `FIX` Remove linux/arm/v5 from docker release action, GH-742
|
||||
|
||||
## 0.16.0 - 2024-06-04
|
||||
|
||||
- `NEW` Allow database stats downloads, GH-738
|
||||
- `NEW` Add analyze table action, GH-737
|
||||
- `NEW` Bump postgres version used in docker compose to 15, GH-729
|
||||
- `NEW` Build on Go 1.22, GH-726
|
||||
- `FIX` SSH tunnel cleanup and parse fixup, GH-731
|
||||
- `FIX` Drop linux/arm/v5 from docker build, GH-728
|
||||
- `FIX` Propagate CGO_ENABLED environment variable to docker build, GH-724
|
||||
|
||||
## 0.15.0 - 2024-03-14
|
||||
|
||||
- `NEW` Add support for a bookmarks-only mode, GH-716
|
||||
- `FIX` Fix missing indexes by quoting schema/table name to ::regclass, GH-711
|
||||
- `FIX` Continue on parseJSON error, GH-708
|
||||
|
||||
## 0.14.3 - 2024-01-28
|
||||
|
||||
- `NEW` Allow retrying a connection on startup, GH-695
|
||||
- `NEW` Allow setting readonly mode in bookmarks, GH-707
|
||||
- `FIX` Add UPDATE to list of restricted keywords in read-only mode, GH-697
|
||||
|
||||
## 0.14.2 - 2023-10-29
|
||||
|
||||
- `NEW` Execute tests using PostgreSQL 16, GH-691
|
||||
- `FIX` Unclosed database sessions and tunnels, GH-688
|
||||
- `FIX` Use pg_table_size for table stats query, GH-685
|
||||
- `FIX` Use `HasSuffix` to correctly determine URL prefix, GH-684
|
||||
|
||||
## 0.14.1 - 2023-06-17
|
||||
|
||||
- `NEW` Add process start time metric, GH-675
|
||||
- `NEW` Configure pgweb user for docker container, GH-674
|
||||
- `NEW` Updated dockerfile, GH-645
|
||||
- `FIX` Fix typo in the healthy metric, GH-657
|
||||
- `NEW` Use entrypoint instead of cmd in in dockerfile, GH-654
|
||||
|
||||
## 0.14.0 - 2023-02-21
|
||||
|
||||
- `FIX` History page query loading fixup, GH-632
|
||||
- `NEW` Display cell content via context menu, GH-634
|
||||
- `NEW` Handle support/permissions errors in info call, GH-635
|
||||
- `NEW` Show error message when API calls fail, GH-636
|
||||
- `NEW` Add bookmark options to load username/password from env vars, GH-638
|
||||
- `NEW` Add context menu to display database tables stats, GH-639
|
||||
- `NEW` Added Local Queries feature, GH-641
|
||||
- `FIX` Ensure that objects are sorted by schema and name, GH-648
|
||||
- `FIX` Fetch local queries on db connect, GH-650
|
||||
|
||||
## 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
|
||||
@@ -102,7 +385,7 @@
|
||||
## 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 delete/truncate table via context many 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
|
||||
|
||||
@@ -116,7 +399,7 @@
|
||||
|
||||
## 0.5.2 - 2015-04-13
|
||||
|
||||
- Adds a new endpoint /activity that retuns active queries
|
||||
- Adds a new endpoint /activity that returns 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
|
||||
|
||||
+1
-1
@@ -2,6 +2,6 @@
|
||||
- Create a new git branch
|
||||
- Make changes
|
||||
- Run tests: `make test`
|
||||
- Run tests agains all supported PostreSQL versions: `make test-all` (optional)
|
||||
- Run tests against 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
|
||||
+44
-13
@@ -1,17 +1,48 @@
|
||||
FROM alpine:3.3
|
||||
MAINTAINER Dan Sosedoff <dan.sosedoff@gmail.com>
|
||||
# ------------------------------------------------------------------------------
|
||||
# Builder Stage
|
||||
# ------------------------------------------------------------------------------
|
||||
FROM golang:1.25-trixie AS build
|
||||
|
||||
ENV PGWEB_VERSION 0.9.7
|
||||
# Set default build argument for CGO_ENABLED
|
||||
ARG CGO_ENABLED=0
|
||||
ENV CGO_ENABLED=${CGO_ENABLED}
|
||||
|
||||
RUN \
|
||||
apk update && \
|
||||
apk add ca-certificates && \
|
||||
update-ca-certificates && \
|
||||
cd /tmp && \
|
||||
wget https://github.com/sosedoff/pgweb/releases/download/v$PGWEB_VERSION/pgweb_linux_amd64.zip && \
|
||||
unzip pgweb_linux_amd64.zip -d /usr/bin && \
|
||||
mv /usr/bin/pgweb_linux_amd64 /usr/bin/pgweb && \
|
||||
rm -f pgweb_linux_amd64.zip
|
||||
WORKDIR /build
|
||||
|
||||
RUN git config --global --add safe.directory /build
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY Makefile main.go ./
|
||||
COPY static/ static/
|
||||
COPY pkg/ pkg/
|
||||
COPY .git/ .
|
||||
RUN make build
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Fetch signing key
|
||||
# ------------------------------------------------------------------------------
|
||||
FROM debian:trixie-slim AS keyring
|
||||
ADD https://www.postgresql.org/media/keys/ACCC4CF8.asc keyring.asc
|
||||
RUN apt-get update && \
|
||||
apt-get install -qq --no-install-recommends gpg
|
||||
RUN gpg -o keyring.pgp --dearmor keyring.asc
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Release Stage
|
||||
# ------------------------------------------------------------------------------
|
||||
FROM debian:trixie-slim
|
||||
|
||||
ARG keyring=/usr/share/keyrings/postgresql-archive-keyring.pgp
|
||||
COPY --from=keyring /keyring.pgp $keyring
|
||||
RUN . /etc/os-release && \
|
||||
echo "deb [signed-by=${keyring}] http://apt.postgresql.org/pub/repos/apt/ ${VERSION_CODENAME}-pgdg main" > /etc/apt/sources.list.d/pgdg.list && \
|
||||
apt-get update && \
|
||||
apt-get install -qq --no-install-recommends ca-certificates openssl netcat-openbsd curl postgresql-client
|
||||
|
||||
COPY --from=build /build/pgweb /usr/bin/pgweb
|
||||
|
||||
RUN useradd --uid 1000 --no-create-home --shell /bin/false pgweb
|
||||
USER pgweb
|
||||
|
||||
EXPOSE 8081
|
||||
CMD ["/usr/bin/pgweb", "--bind=0.0.0.0", "--listen=8081"]
|
||||
ENTRYPOINT ["/usr/bin/pgweb", "--bind=0.0.0.0", "--listen=8081"]
|
||||
|
||||
Generated
-118
@@ -1,118 +0,0 @@
|
||||
{
|
||||
"ImportPath": "github.com/sosedoff/pgweb",
|
||||
"GoVersion": "go1.7",
|
||||
"GodepVersion": "v79",
|
||||
"Packages": [
|
||||
"./..."
|
||||
],
|
||||
"Deps": [
|
||||
{
|
||||
"ImportPath": "github.com/BurntSushi/toml",
|
||||
"Comment": "v0.1.0-9-g3883ac1",
|
||||
"Rev": "3883ac1ce943878302255f538fce319d23226223"
|
||||
},
|
||||
{
|
||||
"ImportPath": "github.com/davecgh/go-spew/spew",
|
||||
"Rev": "5215b55f46b2b919f50a1df0eaa5886afe4e3b3d"
|
||||
},
|
||||
{
|
||||
"ImportPath": "github.com/gin-gonic/gin",
|
||||
"Comment": "v1.1-63-g963acc4",
|
||||
"Rev": "963acc4b0ce297782405d4aefd6fe173ff657b1f"
|
||||
},
|
||||
{
|
||||
"ImportPath": "github.com/gin-gonic/gin/binding",
|
||||
"Comment": "v1.1-63-g963acc4",
|
||||
"Rev": "963acc4b0ce297782405d4aefd6fe173ff657b1f"
|
||||
},
|
||||
{
|
||||
"ImportPath": "github.com/gin-gonic/gin/render",
|
||||
"Comment": "v1.1-63-g963acc4",
|
||||
"Rev": "963acc4b0ce297782405d4aefd6fe173ff657b1f"
|
||||
},
|
||||
{
|
||||
"ImportPath": "github.com/golang/protobuf/proto",
|
||||
"Rev": "8ee79997227bf9b34611aee7946ae64735e6fd93"
|
||||
},
|
||||
{
|
||||
"ImportPath": "github.com/jessevdk/go-flags",
|
||||
"Comment": "v1-285-g1679536",
|
||||
"Rev": "1679536dcc895411a9f5848d9a0250be7856448c"
|
||||
},
|
||||
{
|
||||
"ImportPath": "github.com/jmoiron/sqlx",
|
||||
"Comment": "sqlx-v1.0-61-gb468c08",
|
||||
"Rev": "b468c08552f4efac78b94708eb040170a8184c47"
|
||||
},
|
||||
{
|
||||
"ImportPath": "github.com/jmoiron/sqlx/reflectx",
|
||||
"Comment": "sqlx-v1.0-61-gb468c08",
|
||||
"Rev": "b468c08552f4efac78b94708eb040170a8184c47"
|
||||
},
|
||||
{
|
||||
"ImportPath": "github.com/julienschmidt/httprouter",
|
||||
"Comment": "v1-37-gb55664b",
|
||||
"Rev": "b55664b9e92004aebb7f19a19a9d06271f3a41fc"
|
||||
},
|
||||
{
|
||||
"ImportPath": "github.com/lib/pq",
|
||||
"Comment": "go1.0-cutoff-56-gdc50b6a",
|
||||
"Rev": "dc50b6ad2d3ee836442cf3389009c7cd1e64bb43"
|
||||
},
|
||||
{
|
||||
"ImportPath": "github.com/lib/pq/oid",
|
||||
"Comment": "go1.0-cutoff-56-gdc50b6a",
|
||||
"Rev": "dc50b6ad2d3ee836442cf3389009c7cd1e64bb43"
|
||||
},
|
||||
{
|
||||
"ImportPath": "github.com/mattn/go-isatty",
|
||||
"Rev": "30a891c33c7cde7b02a981314b4228ec99380cca"
|
||||
},
|
||||
{
|
||||
"ImportPath": "github.com/mitchellh/go-homedir",
|
||||
"Rev": "7d2d8c8a4e078ce3c58736ab521a40b37a504c52"
|
||||
},
|
||||
{
|
||||
"ImportPath": "github.com/pmezard/go-difflib/difflib",
|
||||
"Rev": "792786c7400a136282c1664665ae0a8db921c6c2"
|
||||
},
|
||||
{
|
||||
"ImportPath": "github.com/stretchr/testify/assert",
|
||||
"Comment": "v1.1.3",
|
||||
"Rev": "f390dcf405f7b83c997eac1b06768bb9f44dec18"
|
||||
},
|
||||
{
|
||||
"ImportPath": "golang.org/x/crypto/curve25519",
|
||||
"Rev": "ede567c8e044a5913dad1d1af3696d9da953104c"
|
||||
},
|
||||
{
|
||||
"ImportPath": "golang.org/x/crypto/ed25519",
|
||||
"Rev": "ede567c8e044a5913dad1d1af3696d9da953104c"
|
||||
},
|
||||
{
|
||||
"ImportPath": "golang.org/x/crypto/ed25519/internal/edwards25519",
|
||||
"Rev": "ede567c8e044a5913dad1d1af3696d9da953104c"
|
||||
},
|
||||
{
|
||||
"ImportPath": "golang.org/x/crypto/ssh",
|
||||
"Rev": "ede567c8e044a5913dad1d1af3696d9da953104c"
|
||||
},
|
||||
{
|
||||
"ImportPath": "golang.org/x/sys/unix",
|
||||
"Rev": "30237cf4eefd639b184d1f2cb77a581ea0be8947"
|
||||
},
|
||||
{
|
||||
"ImportPath": "gopkg.in/gin-contrib/sse.v0",
|
||||
"Rev": "22d885f9ecc78bf4ee5d72b937e4bbcdc58e8cae"
|
||||
},
|
||||
{
|
||||
"ImportPath": "gopkg.in/go-playground/validator.v8",
|
||||
"Comment": "v8.18.1",
|
||||
"Rev": "5f57d2222ad794d0dffb07e664ea05e2ee07d60c"
|
||||
},
|
||||
{
|
||||
"ImportPath": "gopkg.in/yaml.v2",
|
||||
"Rev": "a5b47d31c556af34a302ce5d659e6fea44d90de0"
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
-5
@@ -1,5 +0,0 @@
|
||||
This directory tree is generated automatically by godep.
|
||||
|
||||
Please do not edit.
|
||||
|
||||
See https://github.com/tools/godep for more information.
|
||||
@@ -1,6 +1,6 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014-2017 Dan Sosedoff <dan.sosedoff@gmail.com>
|
||||
Copyright (c) 2014-2024 Dan Sosedoff <dan.sosedoff@gmail.com>
|
||||
|
||||
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
|
||||
|
||||
@@ -1,85 +1,72 @@
|
||||
TARGETS = darwin/amd64 darwin/386 linux/amd64 linux/386 windows/amd64 windows/386
|
||||
GIT_COMMIT = $(shell git rev-parse HEAD)
|
||||
BUILD_TIME = $(shell date -u +"%Y-%m-%dT%H:%M:%SZ" | tr -d '\n')
|
||||
PKG = github.com/sosedoff/pgweb
|
||||
GIT_COMMIT ?= $(shell git rev-parse --short=8 HEAD)
|
||||
BUILD_TIME ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ" | tr -d '\n')
|
||||
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"
|
||||
BINDATA_IGNORE = $(shell git ls-files -io --exclude-standard $< | sed 's/^/-ignore=/;s/[.]/[.]/g')
|
||||
|
||||
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 setup : Install all necessary dependencies"
|
||||
@echo "make dev : Generate development build"
|
||||
@echo "make build : Generate production build for current OS"
|
||||
@echo "make bootstrap : Install cross-compilation toolchain"
|
||||
@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 assets : Generate production assets file"
|
||||
@echo "make dev-assets : Generate development assets file"
|
||||
@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 -cover ./pkg/...
|
||||
go test -v -race -cover ./pkg/...
|
||||
|
||||
test-all:
|
||||
@./script/test_all.sh
|
||||
@./script/test_cockroach.sh
|
||||
|
||||
assets: static/
|
||||
go-bindata -o pkg/data/bindata.go -pkg data $(BINDATA_OPTS) $(BINDATA_IGNORE) -ignore=[.]gitignore -ignore=[.]gitkeep $<...
|
||||
lint:
|
||||
golangci-lint run
|
||||
|
||||
dev-assets:
|
||||
@$(MAKE) --no-print-directory assets BINDATA_OPTS="-debug"
|
||||
|
||||
dev: dev-assets
|
||||
dev:
|
||||
go build
|
||||
@echo "You can now execute ./pgweb"
|
||||
|
||||
build: assets
|
||||
go build
|
||||
build:
|
||||
go build -ldflags '${LDFLAGS}'
|
||||
@echo "You can now execute ./pgweb"
|
||||
|
||||
release: clean assets
|
||||
install:
|
||||
go install -ldflags '${LDFLAGS}'
|
||||
@echo "You can now execute pgweb"
|
||||
|
||||
release: clean
|
||||
@echo "Building binaries..."
|
||||
@gox \
|
||||
-osarch "$(TARGETS)" \
|
||||
-ldflags "-X github.com/sosedoff/pgweb/pkg/command.GitCommit=$(GIT_COMMIT) -X github.com/sosedoff/pgweb/pkg/command.BuildTime=$(BUILD_TIME)" \
|
||||
-output "./bin/pgweb_{{.OS}}_{{.Arch}}"
|
||||
|
||||
@echo "Building ARM binaries..."
|
||||
GOOS=linux GOARCH=arm GOARM=5 go build \
|
||||
-ldflags "-X github.com/sosedoff/pgweb/pkg/command.GitCommit=$(GIT_COMMIT) -X github.com/sosedoff/pgweb/pkg/command.BuildTime=$(BUILD_TIME)" \
|
||||
-o "./bin/pgweb_linux_arm_v5"
|
||||
|
||||
@echo "\nPackaging binaries...\n"
|
||||
@./script/package.sh
|
||||
|
||||
bootstrap:
|
||||
gox -build-toolchain
|
||||
|
||||
setup:
|
||||
go get github.com/tools/godep
|
||||
go get golang.org/x/tools/cmd/cover
|
||||
go get github.com/mitchellh/gox
|
||||
go get github.com/jteeuwen/go-bindata/...
|
||||
godep restore
|
||||
@LDFLAGS='${LDFLAGS}' ./script/build_all.sh
|
||||
|
||||
clean:
|
||||
@rm -f ./pgweb
|
||||
@rm -rf ./bin/*
|
||||
@rm -f bindata.go
|
||||
@echo "Removing all artifacts"
|
||||
@rm -rf ./pgweb ./bin/*
|
||||
|
||||
docker:
|
||||
docker build -t pgweb .
|
||||
docker build --no-cache -t pgweb .
|
||||
|
||||
docker-run:
|
||||
docker run --rm -p 8081:8081 -it pgweb
|
||||
|
||||
docker-release:
|
||||
docker build -t $(DOCKER_RELEASE_TAG) .
|
||||
docker build -t $(DOCKER_LATEST_TAG) .
|
||||
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)
|
||||
|
||||
@@ -1,47 +1,44 @@
|
||||
# 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://travis-ci.org/sosedoff/pgweb)
|
||||
[](https://ci.appveyor.com/project/sosedoff/pgweb)
|
||||
[](https://github.com/sosedoff/pgweb/actions?query=branch%3Amain)
|
||||
[](https://goreportcard.com/report/github.com/sosedoff/pgweb)
|
||||
[](https://godoc.org/github.com/sosedoff/pgweb)
|
||||
[](https://hub.docker.com/r/sosedoff/pgweb/)
|
||||
|
||||
## Overview
|
||||
|
||||
Pgweb is a web-based database browser for PostgreSQL, written in Go and works
|
||||
on OSX, Linux and Windows machines. Main idea behind using Go for backend development
|
||||
is to utilize ability of the compiler to produce zero-dependency binaries for
|
||||
multiple platforms. Pgweb was created as an attempt to build very simple and portable
|
||||
application to work with local or remote PostgreSQL databases.
|
||||
Pgweb is a web-based database explorer for PostgreSQL, written in Go, and works
|
||||
on Mac, Linux and Windows machines. Distributed as a simple binary with zero dependencies.
|
||||
Very easy to use and packs just the right amount of features.
|
||||
|
||||
[See application screenshots](SCREENS.md)
|
||||
|
||||
## Features
|
||||
|
||||
- Cross-platform support OSX/Linux/Windows 32/64-bit
|
||||
- Simple installation (distributed as a single binary)
|
||||
- Zero dependencies
|
||||
- Works with PostgreSQL 9.1+
|
||||
- SSH Connections
|
||||
- Multiple database sessions
|
||||
- Simple database browser
|
||||
- Execute and analyze custom SQL queries
|
||||
- Table and query data export to CSV/JSON/XML
|
||||
- Query history
|
||||
- Server bookmarks
|
||||
- Cross-platform: Mac/Linux/Windows (64bit).
|
||||
- Simple installation (distributed as a single binary).
|
||||
- Zero dependencies.
|
||||
- Works with PostgreSQL 9.1+.
|
||||
- Supports native SSH tunnels.
|
||||
- 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
|
||||
Visit [WIKI](https://github.com/sosedoff/pgweb/wiki) for more details.
|
||||
|
||||
## Demo
|
||||
|
||||
Visit https://pgweb-demo.herokuapp.com to see pgweb in action.
|
||||
Visit https://pgweb-demo.fly.dev/ to see Pgweb in action.
|
||||
|
||||
## Installation
|
||||
|
||||
[Precompiled binaries](https://github.com/sosedoff/pgweb/releases) for supported
|
||||
operating systems are available.
|
||||
|
||||
[More installation options](https://github.com/sosedoff/pgweb/wiki/Installation)
|
||||
- [Precompiled binaries](https://github.com/sosedoff/pgweb/releases) for supported operating systems are available.
|
||||
- [More installation options](https://github.com/sosedoff/pgweb/wiki/Installation)
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -61,6 +58,7 @@ Connection URL scheme is also supported:
|
||||
|
||||
```
|
||||
pgweb --url postgres://user:password@host:port/database?sslmode=[mode]
|
||||
pgweb --url "postgres:///database?host=/absolute/path/to/unix/socket/dir"
|
||||
```
|
||||
|
||||
### Multiple database sessions
|
||||
@@ -74,13 +72,9 @@ pgweb --sessions
|
||||
Or set environment variable:
|
||||
|
||||
```
|
||||
SESSIONS=1 pgweb
|
||||
PGWEB_SESSIONS=1 pgweb
|
||||
```
|
||||
|
||||
## Deploy on Heroku
|
||||
|
||||
[](https://heroku.com/deploy?template=https://github.com/sosedoff/pgweb)
|
||||
|
||||
## Testing
|
||||
|
||||
Before running tests, make sure you have PostgreSQL server running on `localhost:5432`
|
||||
@@ -110,14 +104,6 @@ make test-all
|
||||
- Use [issues](https://github.com/sosedoff/pgweb/issues) for any questions
|
||||
- Check [wiki](https://github.com/sosedoff/pgweb/wiki) for extra documentation
|
||||
|
||||
## Contact
|
||||
|
||||
- Dan Sosedoff
|
||||
- [dan.sosedoff@gmail.com](mailto:dan.sosedoff@gmail.com)
|
||||
- [http://twitter.com/sosedoff](http://twitter.com/sosedoff)
|
||||
|
||||
## License
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014-2017 Dan Sosedoff, <dan.sosedoff@gmail.com>
|
||||
The MIT License (MIT). See [LICENSE](LICENSE) file for more details.
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"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,27 +0,0 @@
|
||||
clone_folder: c:\go\src\github.com\sosedoff\pgweb
|
||||
|
||||
environment:
|
||||
GOPATH: c:\gopath
|
||||
|
||||
services:
|
||||
- postgresql
|
||||
|
||||
install:
|
||||
- set PATH=%GOPATH%\bin;%PATH%
|
||||
- echo %PATH%
|
||||
- echo %GOPATH%
|
||||
- cd %APPVEYOR_BUILD_FOLDER%
|
||||
- go env
|
||||
- go version
|
||||
- go get github.com/tools/godep
|
||||
- godep get github.com/mitchellh/gox
|
||||
- godep get github.com/jteeuwen/go-bindata/...
|
||||
- godep restore
|
||||
|
||||
build_script:
|
||||
- godep go build
|
||||
|
||||
test_script:
|
||||
- godep go test -cover ./...
|
||||
|
||||
deploy: off
|
||||
@@ -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,6 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem "sinatra"
|
||||
gem "json"
|
||||
gem "puma"
|
||||
gem "rackup"
|
||||
@@ -0,0 +1,44 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
base64 (0.3.0)
|
||||
json (2.12.2)
|
||||
logger (1.7.0)
|
||||
mustermann (3.0.3)
|
||||
ruby2_keywords (~> 0.0.1)
|
||||
nio4r (2.7.4)
|
||||
puma (6.6.0)
|
||||
nio4r (~> 2.0)
|
||||
rack (3.1.16)
|
||||
rack-protection (4.1.1)
|
||||
base64 (>= 0.1.0)
|
||||
logger (>= 1.6.0)
|
||||
rack (>= 3.0.0, < 4)
|
||||
rack-session (2.1.1)
|
||||
base64 (>= 0.1.0)
|
||||
rack (>= 3.0.0)
|
||||
rackup (2.2.1)
|
||||
rack (>= 3)
|
||||
ruby2_keywords (0.0.5)
|
||||
sinatra (4.1.1)
|
||||
logger (>= 1.6.0)
|
||||
mustermann (~> 3.0)
|
||||
rack (>= 3.0.0, < 4)
|
||||
rack-protection (= 4.1.1)
|
||||
rack-session (>= 2.0.0, < 3)
|
||||
tilt (~> 2.0)
|
||||
tilt (2.6.0)
|
||||
|
||||
PLATFORMS
|
||||
arm64-darwin-21
|
||||
arm64-darwin-24
|
||||
x86_64-linux
|
||||
|
||||
DEPENDENCIES
|
||||
json
|
||||
puma
|
||||
rackup
|
||||
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,41 @@
|
||||
require "bundler/setup"
|
||||
require "sinatra"
|
||||
|
||||
# Authentication token
|
||||
$token = "test"
|
||||
|
||||
# List of all available 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,12 @@
|
||||
host = "localhost"
|
||||
port = 5432
|
||||
user = "postgres"
|
||||
database = "mydatabase"
|
||||
ssl = "disable"
|
||||
|
||||
[SSH]
|
||||
host = "ssh-host"
|
||||
user = "ssh-user"
|
||||
password = "ssh-password"
|
||||
key = "/path/to/key-file"
|
||||
keypassword = "key-file-password"
|
||||
+2
-2
@@ -10,7 +10,7 @@
|
||||
DROP DATABASE IF EXISTS "booktown";
|
||||
CREATE DATABASE "booktown";
|
||||
|
||||
\connect booktown postgres
|
||||
-- \connect booktown postgres
|
||||
--
|
||||
-- TOC Entry ID 2 (OID 2991542)
|
||||
--
|
||||
@@ -1293,7 +1293,7 @@ CREATE VIEW "recent_shipments" as SELECT count(*) AS num_shipped, max(shipments.
|
||||
|
||||
|
||||
COPY "publishers" FROM stdin;
|
||||
150 Kids Can Press Kids Can Press, 29 Birch Ave. Toronto, ON M4V 1E2
|
||||
150 Kids Can Press Kids Can Press, 29 Birch Ave. Toronto,�ON��M4V 1E2
|
||||
91 Henry Holt & Company, Inc. Henry Holt & Company, Inc. 115 West 18th Street New York, NY 10011
|
||||
113 O'Reilly & Associates O'Reilly & Associates, Inc. 101 Morris St, Sebastopol, CA 95472
|
||||
62 Watson-Guptill Publications 1515 Boradway, New York, NY 10036
|
||||
|
||||
@@ -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,61 @@
|
||||
---
|
||||
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:
|
||||
postgres18:
|
||||
<<: *base
|
||||
image: postgres:18
|
||||
ports:
|
||||
- 5433:5432
|
||||
postgres17:
|
||||
<<: *base
|
||||
image: postgres:17
|
||||
ports:
|
||||
- 5433:5432
|
||||
postgres16:
|
||||
<<: *base
|
||||
image: postgres:16
|
||||
ports:
|
||||
- 5433:5432
|
||||
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,43 @@
|
||||
---
|
||||
services:
|
||||
postgres:
|
||||
container_name: pgweb-postgres
|
||||
image: postgres:15
|
||||
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
|
||||
build: .
|
||||
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 --idle-timeout=30"
|
||||
|
||||
[[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,64 @@
|
||||
module github.com/sosedoff/pgweb
|
||||
|
||||
go 1.25
|
||||
|
||||
toolchain go1.25.4
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.1.0
|
||||
github.com/ScaleFT/sshkeys v0.0.0-20200327173127-6142f742bca5
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
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.19.1
|
||||
github.com/sirupsen/logrus v1.9.1
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/tuvistavie/securerandom v0.0.0-20140719024926-15512123a948
|
||||
go.balki.me/anyhttp v0.5.0
|
||||
golang.org/x/crypto v0.46.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.14.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.4.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dchest/bcrypt_pbkdf v0.0.0-20150205184540-83f37f9c154a // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.1 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_model v0.5.0 // indirect
|
||||
github.com/prometheus/common v0.48.0 // indirect
|
||||
github.com/prometheus/procfs v0.12.0 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.58.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.uber.org/mock v0.6.0 // indirect
|
||||
golang.org/x/arch v0.23.0 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
@@ -0,0 +1,151 @@
|
||||
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/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
|
||||
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
|
||||
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
|
||||
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
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/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
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/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
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/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.1 h1:3rG3+v8pkhRqoQ/88NYNMHYVGYztCOCIZ7UQhu7H+NE=
|
||||
github.com/goccy/go-yaml v1.19.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
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.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
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.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
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/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 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/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
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.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
|
||||
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
|
||||
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
|
||||
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
|
||||
github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE=
|
||||
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
|
||||
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
|
||||
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.58.0 h1:ggY2pvZaVdB9EyojxL1p+5mptkuHyX5MOSv4dgWF4Ug=
|
||||
github.com/quic-go/quic-go v0.58.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/sirupsen/logrus v1.9.1 h1:Ou41VVR3nMWWmTiEUnj0OlsgOSCUFgsPAOl6jRIcVtQ=
|
||||
github.com/sirupsen/logrus v1.9.1/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/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
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/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
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/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.balki.me/anyhttp v0.5.0 h1:uys0oRciBpZfwtxXAevScKy6amIQBXyDrcV0EtGF5zo=
|
||||
go.balki.me/anyhttp v0.5.0/go.mod h1:JhfekOIjgVODoVqUCficjpIgmB3wwlB7jhN0eN2EZ/s=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
|
||||
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
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.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
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-20200219091948-cb0a6d8edb6c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
|
||||
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/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/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/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=
|
||||
+454
-120
@@ -1,34 +1,48 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"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/connect"
|
||||
"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 = map[string]*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[getSessionId(c.Request)]
|
||||
} else {
|
||||
return DbClient
|
||||
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 {
|
||||
@@ -40,69 +54,120 @@ func setClient(c *gin.Context, newClient *client.Client) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
sessionId := getSessionId(c.Request)
|
||||
if sessionId == "" {
|
||||
return errors.New("Session ID is required")
|
||||
sid := getSessionId(c.Request)
|
||||
if sid == "" {
|
||||
return errSessionRequired
|
||||
}
|
||||
|
||||
DbSessions[sessionId] = newClient
|
||||
DbSessions.Add(sid, newClient)
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetHome(c *gin.Context) {
|
||||
serveStaticAsset("/index.html", c)
|
||||
// GetHome renders the home page
|
||||
func GetHome(prefix string) http.Handler {
|
||||
if prefix != "" {
|
||||
prefix = "/" + prefix
|
||||
}
|
||||
return http.StripPrefix(prefix, static.GetHandler())
|
||||
}
|
||||
|
||||
func GetAsset(c *gin.Context) {
|
||||
serveStaticAsset(c.Params.ByName("path"), c)
|
||||
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 {
|
||||
c.JSON(200, DbSessions)
|
||||
successResponse(c, DbSessions.Sessions())
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, map[string]int{"sessions": len(DbSessions)})
|
||||
successResponse(c, gin.H{"sessions": DbSessions.Len()})
|
||||
}
|
||||
|
||||
// ConnectWithBackend creates a new connection based on backend resource
|
||||
func ConnectWithBackend(c *gin.Context) {
|
||||
backend := connect.NewBackend(command.Opts.ConnectBackend, command.Opts.ConnectToken)
|
||||
backend.SetLogger(logger)
|
||||
|
||||
if command.Opts.ConnectHeaders != "" {
|
||||
backend.SetPassHeaders(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.Request.Header)
|
||||
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 {
|
||||
c.JSON(400, Error{"Session is locked"})
|
||||
badRequest(c, errSessionLocked)
|
||||
return
|
||||
}
|
||||
|
||||
var sshInfo *shared.SSHInfo
|
||||
url := c.Request.FormValue("url")
|
||||
var (
|
||||
cl *client.Client
|
||||
err error
|
||||
)
|
||||
|
||||
if url == "" {
|
||||
c.JSON(400, Error{"Url parameter is required"})
|
||||
return
|
||||
if bookmarkID := c.Request.FormValue("bookmark_id"); bookmarkID != "" {
|
||||
cl, err = ConnectWithBookmark(bookmarkID)
|
||||
} else if command.Opts.BookmarksOnly {
|
||||
err = errNotPermitted
|
||||
} else {
|
||||
cl, err = ConnectWithURL(c)
|
||||
}
|
||||
|
||||
opts := command.Options{Url: url}
|
||||
url, err := connection.FormatUrl(opts)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(400, Error{err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if c.Request.FormValue("ssh") != "" {
|
||||
sshInfo = parseSshInfo(c)
|
||||
}
|
||||
|
||||
cl, err := client.NewFromUrl(url, sshInfo)
|
||||
if err != nil {
|
||||
c.JSON(400, Error{err.Error()})
|
||||
badRequest(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = cl.Test()
|
||||
if err != nil {
|
||||
c.JSON(400, Error{err.Error()})
|
||||
badRequest(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -112,16 +177,50 @@ func Connect(c *gin.Context) {
|
||||
}
|
||||
if err != nil {
|
||||
cl.Close()
|
||||
c.JSON(400, Error{err.Error()})
|
||||
badRequest(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, info.Format()[0])
|
||||
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 {
|
||||
c.JSON(400, Error{"Session is locked"})
|
||||
badRequest(c, errSessionLocked)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -130,33 +229,38 @@ func SwitchDb(c *gin.Context) {
|
||||
name = c.Request.FormValue("db")
|
||||
}
|
||||
if name == "" {
|
||||
c.JSON(400, Error{"Database name is not provided"})
|
||||
badRequest(c, errDatabaseNameRequired)
|
||||
return
|
||||
}
|
||||
|
||||
conn := DB(c)
|
||||
if conn == nil {
|
||||
c.JSON(400, Error{"Not connected"})
|
||||
badRequest(c, errNotConnected)
|
||||
return
|
||||
}
|
||||
|
||||
currentUrl, err := neturl.Parse(conn.ConnectionString)
|
||||
if err != nil {
|
||||
c.JSON(400, Error{"Unable to parse current connection string"})
|
||||
// Do not allow switching databases for connections from third-party backends
|
||||
if conn.External {
|
||||
badRequest(c, errSessionLocked)
|
||||
return
|
||||
}
|
||||
|
||||
currentUrl.Path = name
|
||||
|
||||
cl, err := client.NewFromUrl(currentUrl.String(), nil)
|
||||
currentURL, err := neturl.Parse(conn.ConnectionString)
|
||||
if err != nil {
|
||||
c.JSON(400, Error{err.Error()})
|
||||
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 {
|
||||
c.JSON(400, Error{err.Error()})
|
||||
badRequest(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -166,103 +270,145 @@ func SwitchDb(c *gin.Context) {
|
||||
}
|
||||
if err != nil {
|
||||
cl.Close()
|
||||
c.JSON(400, Error{err.Error()})
|
||||
badRequest(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
conn.Close()
|
||||
|
||||
c.JSON(200, info.Format()[0])
|
||||
successResponse(c, info.Format()[0])
|
||||
}
|
||||
|
||||
// Disconnect closes the current database connection
|
||||
func Disconnect(c *gin.Context) {
|
||||
if command.Opts.LockSession {
|
||||
c.JSON(400, Error{"Session is locked"})
|
||||
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 {
|
||||
c.JSON(400, Error{"Not connected"})
|
||||
badRequest(c, errNotConnected)
|
||||
return
|
||||
}
|
||||
|
||||
err := conn.Close()
|
||||
if err != nil {
|
||||
c.JSON(400, Error{err.Error()})
|
||||
badRequest(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
func GetDatabases(c *gin.Context) {
|
||||
names, err := DB(c).Databases()
|
||||
serveResult(names, err, c)
|
||||
}
|
||||
|
||||
func GetObjects(c *gin.Context) {
|
||||
result, err := DB(c).Objects()
|
||||
if err != nil {
|
||||
c.JSON(400, NewError(err))
|
||||
return
|
||||
}
|
||||
|
||||
objects := client.ObjectsFromResult(result)
|
||||
c.JSON(200, objects)
|
||||
DbClient = nil
|
||||
successResponse(c, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// RunQuery executes the query
|
||||
func RunQuery(c *gin.Context) {
|
||||
query := cleanQuery(c.Request.FormValue("query"))
|
||||
|
||||
if query == "" {
|
||||
c.JSON(400, NewError(errors.New("Query parameter is missing")))
|
||||
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 == "" {
|
||||
c.JSON(400, NewError(errors.New("Query parameter is missing")))
|
||||
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)
|
||||
}
|
||||
|
||||
func GetSchemas(c *gin.Context) {
|
||||
res, err := DB(c).Schemas()
|
||||
serveResult(res, err, c)
|
||||
}
|
||||
|
||||
func GetTable(c *gin.Context) {
|
||||
var res *client.Result
|
||||
var err error
|
||||
|
||||
if c.Request.FormValue("type") == "materialized_view" {
|
||||
res, err = DB(c).MaterializedView(c.Params.ByName("table"))
|
||||
} else {
|
||||
res, err = DB(c).Table(c.Params.ByName("table"))
|
||||
// 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
|
||||
}
|
||||
|
||||
serveResult(res, err, c)
|
||||
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 {
|
||||
c.JSON(400, NewError(err))
|
||||
badRequest(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
limit, err := parseIntFormValue(c, "limit", 100)
|
||||
if err != nil {
|
||||
c.JSON(400, NewError(err))
|
||||
badRequest(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -276,13 +422,13 @@ func GetTableRows(c *gin.Context) {
|
||||
|
||||
res, err := DB(c).TableRows(c.Params.ByName("table"), opts)
|
||||
if err != nil {
|
||||
c.JSON(400, NewError(err))
|
||||
badRequest(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
countRes, err := DB(c).TableRowsCount(c.Params.ByName("table"), opts)
|
||||
if err != nil {
|
||||
c.JSON(400, NewError(err))
|
||||
badRequest(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -302,54 +448,114 @@ func GetTableRows(c *gin.Context) {
|
||||
PerPage: numFetch,
|
||||
}
|
||||
|
||||
serveResult(res, err, c)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
c.JSON(400, NewError(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
|
||||
}
|
||||
|
||||
c.JSON(200, res.Format()[0])
|
||||
}
|
||||
|
||||
func GetHistory(c *gin.Context) {
|
||||
c.JSON(200, DB(c).History)
|
||||
}
|
||||
|
||||
func GetConnectionInfo(c *gin.Context) {
|
||||
res, err := DB(c).Info()
|
||||
|
||||
res, err := conn.Info()
|
||||
if err != nil {
|
||||
c.JSON(400, NewError(err))
|
||||
badRequest(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
info := res.Format()[0]
|
||||
info["session_lock"] = command.Opts.LockSession
|
||||
|
||||
c.JSON(200, info)
|
||||
successResponse(c, info)
|
||||
}
|
||||
|
||||
// GetServerSettings renders a list of all server settings
|
||||
func GetServerSettings(c *gin.Context) {
|
||||
res, err := DB(c).ServerSettings()
|
||||
serveResult(c, res, err)
|
||||
}
|
||||
|
||||
// GetActivity renders a list of running queries
|
||||
func GetActivity(c *gin.Context) {
|
||||
res, err := DB(c).Activity()
|
||||
serveResult(res, err, c)
|
||||
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(res, err, c)
|
||||
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(res, err, c)
|
||||
serveResult(c, res, err)
|
||||
}
|
||||
|
||||
// GetTablesStats renders data sizes and estimated rows for all tables in the database
|
||||
func GetTablesStats(c *gin.Context) {
|
||||
db := DB(c)
|
||||
|
||||
connCtx, err := db.GetConnContext()
|
||||
if err != nil {
|
||||
badRequest(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
res, err := db.TablesStats()
|
||||
if err != nil {
|
||||
badRequest(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
format := getQueryParam(c, "format")
|
||||
if format == "" {
|
||||
format = "json"
|
||||
}
|
||||
|
||||
// Save as attachment if exporting parameter is set
|
||||
if getQueryParam(c, "export") == "true" {
|
||||
ts := time.Now().Format(time.DateOnly)
|
||||
|
||||
filename := fmt.Sprintf("pgweb-dbstats-%s-%s.%s", connCtx.Database, ts, format)
|
||||
c.Writer.Header().Set("Content-disposition", "attachment;filename="+filename)
|
||||
}
|
||||
|
||||
switch format {
|
||||
case "json":
|
||||
c.JSON(http.StatusOK, res)
|
||||
case "csv":
|
||||
c.Data(http.StatusOK, "text/csv", res.CSV())
|
||||
case "xml":
|
||||
c.XML(200, res)
|
||||
default:
|
||||
badRequest(c, "invalid format")
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -357,7 +563,7 @@ func HandleQuery(query string, c *gin.Context) {
|
||||
|
||||
result, err := DB(c).Query(query)
|
||||
if err != nil {
|
||||
c.JSON(400, NewError(err))
|
||||
badRequest(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -384,17 +590,145 @@ func HandleQuery(query string, c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// GetBookmarks renders the list of available bookmarks
|
||||
func GetBookmarks(c *gin.Context) {
|
||||
bookmarks, err := bookmarks.ReadAll(bookmarks.Path(command.Opts.BookmarksDir))
|
||||
serveResult(bookmarks, err, c)
|
||||
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) {
|
||||
info := map[string]string{
|
||||
"version": command.VERSION,
|
||||
"git_sha": command.GitCommit,
|
||||
"build_time": command.BuildTime,
|
||||
successResponse(c, gin.H{
|
||||
"app": command.Info,
|
||||
"features": gin.H{
|
||||
"session_lock": command.Opts.LockSession,
|
||||
"query_timeout": command.Opts.QueryTimeout,
|
||||
"local_queries": QueryStore != nil,
|
||||
"bookmarks_only": command.Opts.BookmarksOnly,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// DataExport performs database table export
|
||||
func DataExport(c *gin.Context) {
|
||||
db := DB(c)
|
||||
|
||||
info, err := db.Info()
|
||||
if err != nil {
|
||||
badRequest(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, info)
|
||||
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)
|
||||
}
|
||||
|
||||
+13
-6
@@ -17,17 +17,24 @@ func Test_assetContentType(t *testing.T) {
|
||||
"foo.gif": "image/gif",
|
||||
"foo.eot": "application/vnd.ms-fontobject",
|
||||
"foo.svg": "image/svg+xml",
|
||||
"foo.ttf": "application/x-font-ttf",
|
||||
"foo.foo": "text/plain; charset=utf-8",
|
||||
"foo": "text/plain; charset=utf-8",
|
||||
}
|
||||
|
||||
for name, expected := range samples {
|
||||
assert.Equal(t, expected, assetContentType(name))
|
||||
alternatives := map[string]string{
|
||||
"foo.js": "text/javascript; charset=utf-8",
|
||||
}
|
||||
|
||||
result := assetContentType("foo.woff")
|
||||
if result != "application/x-font-woff" && result != "application/font-woff" {
|
||||
t.Errorf("Expected: application/x-font-woff, Got: %s", result)
|
||||
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,16 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
var (
|
||||
errNotConnected = errors.New("Not connected")
|
||||
errNotPermitted = errors.New("Not permitted")
|
||||
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")
|
||||
)
|
||||
+58
-8
@@ -5,6 +5,7 @@ import (
|
||||
"mime"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -13,30 +14,36 @@ import (
|
||||
"github.com/sosedoff/pgweb/pkg/shared"
|
||||
)
|
||||
|
||||
var extraMimeTypes = map[string]string{
|
||||
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
|
||||
var allowedPaths = map[string]bool{
|
||||
// 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.
|
||||
var base64subs = map[string]string{
|
||||
// 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"`
|
||||
@@ -71,6 +78,11 @@ func desanitize64(query string) string {
|
||||
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 == "" {
|
||||
@@ -115,6 +127,8 @@ func parseSshInfo(c *gin.Context) *shared.SSHInfo {
|
||||
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 == "" {
|
||||
@@ -138,3 +152,39 @@ func assetContentType(name string) string {
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Send a query result to client
|
||||
func serveResult(c *gin.Context, result interface{}, err interface{}) {
|
||||
if err != nil {
|
||||
badRequest(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
successResponse(c, result)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -27,6 +30,22 @@ func Test_cleanQuery(t *testing.T) {
|
||||
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")
|
||||
@@ -36,3 +55,34 @@ func Test_getSessionId(t *testing.T) {
|
||||
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}))
|
||||
}
|
||||
}
|
||||
+22
-31
@@ -1,30 +1,28 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/sosedoff/pgweb/pkg/command"
|
||||
"github.com/sosedoff/pgweb/pkg/data"
|
||||
)
|
||||
|
||||
// Middleware function to check database connection status before running queries
|
||||
// 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)
|
||||
|
||||
if allowedPaths[path] == true {
|
||||
// Allow whitelisted paths
|
||||
if allowedPaths[path] {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// We dont care about sessions unless they're enabled
|
||||
// Check if session exists in single-session mode
|
||||
if !command.Opts.Sessions {
|
||||
if DbClient == nil {
|
||||
c.JSON(400, Error{"Not connected"})
|
||||
c.Abort()
|
||||
badRequest(c, errNotConnected)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -32,17 +30,17 @@ func dbCheckMiddleware() gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
sessionId := getSessionId(c.Request)
|
||||
if sessionId == "" {
|
||||
c.JSON(400, Error{"Session ID is required"})
|
||||
c.Abort()
|
||||
// Determine session ID from the client request
|
||||
sid := getSessionId(c.Request)
|
||||
if sid == "" {
|
||||
badRequest(c, errSessionRequired)
|
||||
return
|
||||
}
|
||||
|
||||
conn := DbSessions[sessionId]
|
||||
// Determine the database connection handle for the session
|
||||
conn := DbSessions.Get(sid)
|
||||
if conn == nil {
|
||||
c.JSON(400, Error{"Not connected"})
|
||||
c.Abort()
|
||||
badRequest(c, errNotConnected)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -50,29 +48,22 @@ func dbCheckMiddleware() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware function to print out request parameters and body for debugging
|
||||
func requestInspectMiddleware() gin.HandlerFunc {
|
||||
// Middleware to inject CORS headers
|
||||
func corsMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
err := c.Request.ParseForm()
|
||||
log.Println("Request params:", err, c.Request.Form)
|
||||
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 serveStaticAsset(path string, c *gin.Context) {
|
||||
data, err := data.Asset("static" + path)
|
||||
if err != nil {
|
||||
c.String(400, err.Error())
|
||||
func requireLocalQueries() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if QueryStore == nil {
|
||||
badRequest(c, "local queries are disabled")
|
||||
return
|
||||
}
|
||||
|
||||
c.Data(200, assetContentType(path), data)
|
||||
}
|
||||
|
||||
func serveResult(result interface{}, err error, c *gin.Context) {
|
||||
if err != nil {
|
||||
c.JSON(400, NewError(err))
|
||||
return
|
||||
c.Next()
|
||||
}
|
||||
|
||||
c.JSON(200, result)
|
||||
}
|
||||
|
||||
+24
-7
@@ -2,25 +2,27 @@ 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.Debug {
|
||||
group.Use(requestInspectMiddleware())
|
||||
if command.Opts.Cors {
|
||||
group.Use(corsMiddleware())
|
||||
}
|
||||
|
||||
group.Use(dbCheckMiddleware())
|
||||
}
|
||||
|
||||
func SetupRoutes(router *gin.Engine) {
|
||||
group := router.Group(command.Opts.Prefix)
|
||||
root := router.Group(command.Opts.Prefix)
|
||||
|
||||
group.GET("/", GetHome)
|
||||
group.GET("/static/*path", GetAsset)
|
||||
root.GET("/", gin.WrapH(GetHome(command.Opts.Prefix)))
|
||||
root.GET("/static/*path", gin.WrapH(GetAssets(command.Opts.Prefix)))
|
||||
root.GET("/connect/:resource", ConnectWithBackend)
|
||||
|
||||
api := group.Group("/api")
|
||||
{
|
||||
api := root.Group("/api")
|
||||
SetupMiddlewares(api)
|
||||
|
||||
if command.Opts.Sessions {
|
||||
@@ -33,6 +35,7 @@ func SetupRoutes(router *gin.Engine) {
|
||||
api.POST("/switchdb", SwitchDb)
|
||||
api.GET("/databases", GetDatabases)
|
||||
api.GET("/connection", GetConnectionInfo)
|
||||
api.GET("/server_settings", GetServerSettings)
|
||||
api.GET("/activity", GetActivity)
|
||||
api.GET("/schemas", GetSchemas)
|
||||
api.GET("/objects", GetObjects)
|
||||
@@ -41,11 +44,25 @@ func SetupRoutes(router *gin.Engine) {
|
||||
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 == "" {
|
||||
// NOTE: We're not supporting the MetricsPath CLI option here to avoid the route conflicts.
|
||||
engine.GET("/metrics", gin.WrapH(metrics.NewHandler()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
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()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
sessions := make(map[string]*client.Client, len(m.sessions))
|
||||
for k, v := range m.sessions {
|
||||
sessions[k] = v
|
||||
}
|
||||
|
||||
return sessions
|
||||
}
|
||||
|
||||
func (m *SessionManager) Get(id string) *client.Client {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
return m.sessions[id]
|
||||
}
|
||||
|
||||
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()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
return len(m.sessions)
|
||||
}
|
||||
|
||||
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.Lock()
|
||||
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"`
|
||||
}
|
||||
+32
-110
@@ -1,131 +1,53 @@
|
||||
package bookmarks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
"github.com/mitchellh/go-homedir"
|
||||
"os"
|
||||
|
||||
"github.com/sosedoff/pgweb/pkg/command"
|
||||
"github.com/sosedoff/pgweb/pkg/shared"
|
||||
)
|
||||
|
||||
// Bookmark contains information about bookmarked database connection
|
||||
type Bookmark struct {
|
||||
Url string `json:"url"` // Postgres connection URL
|
||||
Host string `json:"host"` // Server hostname
|
||||
Port int `json:"port"` // Server port
|
||||
User string `json:"user"` // Database user
|
||||
Password string `json:"password"` // User password
|
||||
Database string `json:"database"` // Database name
|
||||
Ssl string `json:"ssl"` // Connection SSL mode
|
||||
Ssh shared.SSHInfo `json:"ssh"` // SSH tunnel config
|
||||
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
|
||||
ReadOnly bool // Enable read-only transaction mode
|
||||
}
|
||||
|
||||
// SSHInfoIsEmpty returns true if ssh configuration is not provided
|
||||
func (b Bookmark) SSHInfoIsEmpty() bool {
|
||||
return b.Ssh.User == "" && b.Ssh.Host == "" && b.Ssh.Port == ""
|
||||
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,
|
||||
URL: b.URL,
|
||||
Host: b.Host,
|
||||
Port: b.Port,
|
||||
User: b.User,
|
||||
Pass: b.Password,
|
||||
User: user,
|
||||
Pass: pass,
|
||||
DbName: b.Database,
|
||||
Ssl: b.Ssl,
|
||||
SSLMode: b.SSLMode,
|
||||
ReadOnly: b.ReadOnly,
|
||||
}
|
||||
}
|
||||
|
||||
func readServerConfig(path string) (Bookmark, error) {
|
||||
bookmark := Bookmark{}
|
||||
|
||||
buff, err := ioutil.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 by portgres modes
|
||||
modes := []string{"disable", "allow", "prefer", "required", "verify-ca", "verify-full"}
|
||||
valid := false
|
||||
|
||||
for _, mode := range modes {
|
||||
if bookmark.Ssl == 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.Ssl == "" || !valid {
|
||||
bookmark.Ssl = "disable"
|
||||
}
|
||||
|
||||
return bookmark, err
|
||||
}
|
||||
|
||||
func fileBasename(path string) string {
|
||||
filename := filepath.Base(path)
|
||||
return strings.Replace(filename, filepath.Ext(path), "", 1)
|
||||
}
|
||||
|
||||
func Path(overrideDir string) string {
|
||||
if overrideDir == "" {
|
||||
path, _ := homedir.Dir()
|
||||
return fmt.Sprintf("%s/.pgweb/bookmarks", path)
|
||||
}
|
||||
|
||||
return overrideDir
|
||||
}
|
||||
|
||||
func ReadAll(path string) (map[string]Bookmark, error) {
|
||||
results := map[string]Bookmark{}
|
||||
|
||||
files, err := ioutil.ReadDir(path)
|
||||
if err != nil {
|
||||
return results, err
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
if filepath.Ext(file.Name()) != ".toml" {
|
||||
continue
|
||||
}
|
||||
|
||||
fullPath := path + "/" + file.Name()
|
||||
key := fileBasename(file.Name())
|
||||
config, err := readServerConfig(fullPath)
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("%s parse error: %s\n", fullPath, err)
|
||||
continue
|
||||
}
|
||||
|
||||
results[key] = config
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func GetBookmark(bookmarkPath string, bookmarkName string) (Bookmark, error) {
|
||||
bookmarks, err := ReadAll(bookmarkPath)
|
||||
if err != nil {
|
||||
return Bookmark{}, err
|
||||
}
|
||||
|
||||
bookmark, ok := bookmarks[bookmarkName]
|
||||
if !ok {
|
||||
return Bookmark{}, fmt.Errorf("couldn't find a bookmark with name %s", bookmarkName)
|
||||
}
|
||||
|
||||
return bookmark, nil
|
||||
}
|
||||
|
||||
@@ -8,131 +8,115 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_Invalid_Bookmark_Files(t *testing.T) {
|
||||
_, err := readServerConfig("foobar")
|
||||
assert.Error(t, err)
|
||||
|
||||
_, err = readServerConfig("../../data/invalid.toml")
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "Near line 1, key 'invalid encoding': Near line 2: Expected key separator '=', but got '\\n' instead.", err.Error())
|
||||
}
|
||||
|
||||
func Test_Bookmark(t *testing.T) {
|
||||
bookmark, err := readServerConfig("../../data/bookmark.toml")
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, "localhost", bookmark.Host)
|
||||
assert.Equal(t, 5432, bookmark.Port)
|
||||
assert.Equal(t, "postgres", bookmark.User)
|
||||
assert.Equal(t, "mydatabase", bookmark.Database)
|
||||
assert.Equal(t, "disable", bookmark.Ssl)
|
||||
assert.Equal(t, "", bookmark.Password)
|
||||
assert.Equal(t, "", bookmark.Url)
|
||||
|
||||
bookmark, err = readServerConfig("../../data/bookmark_invalid_ssl.toml")
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, "disable", bookmark.Ssl)
|
||||
}
|
||||
|
||||
func Test_Bookmark_URL(t *testing.T) {
|
||||
bookmark, err := readServerConfig("../../data/bookmark_url.toml")
|
||||
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, "postgres://username:password@host:port/database?sslmode=disable", bookmark.Url)
|
||||
assert.Equal(t, "", bookmark.Host)
|
||||
assert.Equal(t, 5432, bookmark.Port)
|
||||
assert.Equal(t, "", bookmark.User)
|
||||
assert.Equal(t, "", bookmark.Database)
|
||||
assert.Equal(t, "disable", bookmark.Ssl)
|
||||
assert.Equal(t, "", bookmark.Password)
|
||||
}
|
||||
|
||||
func Test_Bookmarks_Path(t *testing.T) {
|
||||
assert.NotEqual(t, "/.pgweb/bookmarks", Path(""))
|
||||
}
|
||||
|
||||
func Test_Basename(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_ReadBookmarks_Invalid(t *testing.T) {
|
||||
bookmarks, err := ReadAll("foobar")
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, 0, len(bookmarks))
|
||||
}
|
||||
|
||||
func Test_ReadBookmarks(t *testing.T) {
|
||||
bookmarks, err := ReadAll("../../data")
|
||||
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, 3, len(bookmarks))
|
||||
}
|
||||
|
||||
func Test_GetBookmark(t *testing.T) {
|
||||
expBookmark := Bookmark{
|
||||
|
||||
Host: "localhost",
|
||||
Port: 5432,
|
||||
User: "postgres",
|
||||
Password: "",
|
||||
Database: "mydatabase",
|
||||
Ssl: "disable",
|
||||
}
|
||||
b, err := GetBookmark("../../data", "bookmark")
|
||||
if assert.NoError(t, err) {
|
||||
assert.Equal(t, expBookmark, b)
|
||||
}
|
||||
|
||||
_, err = GetBookmark("../../data", "bar")
|
||||
expErrStr := "couldn't find a bookmark with name bar"
|
||||
assert.Equal(t, expErrStr, err.Error())
|
||||
|
||||
_, err = GetBookmark("foo", "bookmark")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func Test_Bookmark_SSHInfoIsEmpty(t *testing.T) {
|
||||
emptySSH := shared.SSHInfo{
|
||||
func TestBookmarkSSHInfoIsEmpty(t *testing.T) {
|
||||
t.Run("empty", func(t *testing.T) {
|
||||
info := &shared.SSHInfo{
|
||||
Host: "",
|
||||
Port: "",
|
||||
User: "",
|
||||
}
|
||||
populatedSSH := shared.SSHInfo{
|
||||
|
||||
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: emptySSH}
|
||||
assert.True(t, b.SSHInfoIsEmpty())
|
||||
|
||||
b.Ssh = populatedSSH
|
||||
b := Bookmark{SSH: info}
|
||||
assert.False(t, b.SSHInfoIsEmpty())
|
||||
})
|
||||
}
|
||||
|
||||
func Test_ConvertToOptions(t *testing.T) {
|
||||
func TestBookmarkWithVarsConvertToOptions(t *testing.T) {
|
||||
t.Run("literals set", func(t *testing.T) {
|
||||
b := Bookmark{
|
||||
Url: "postgres://username:password@host:port/database?sslmode=disable",
|
||||
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",
|
||||
Ssl: "disable",
|
||||
SSLMode: "disable",
|
||||
ReadOnly: true,
|
||||
}
|
||||
|
||||
expOpt := command.Options{
|
||||
Url: "postgres://username:password@host:port/database?sslmode=disable",
|
||||
URL: "postgres://username:password@host:port/database?sslmode=disable",
|
||||
Host: "localhost",
|
||||
Port: 5432,
|
||||
User: "postgres",
|
||||
Pass: "password",
|
||||
DbName: "mydatabase",
|
||||
Ssl: "disable",
|
||||
SSLMode: "disable",
|
||||
ReadOnly: true,
|
||||
}
|
||||
|
||||
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,116 @@
|
||||
package bookmarks
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestManagerList(t *testing.T) {
|
||||
examples := []struct {
|
||||
dir string
|
||||
num int
|
||||
err string
|
||||
}{
|
||||
{"../../data", 4, ""},
|
||||
{"../../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",
|
||||
"bookmark_with_ssh",
|
||||
}, 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("with ssh options", func(t *testing.T) {
|
||||
b, err := readBookmark("../../data/bookmark_with_ssh.toml")
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, b.SSH)
|
||||
|
||||
sshc := b.SSH
|
||||
assert.Equal(t, "ssh-host", sshc.Host)
|
||||
assert.Equal(t, "ssh-user", sshc.User)
|
||||
assert.Equal(t, "ssh-password", sshc.Password)
|
||||
assert.Equal(t, "/path/to/key-file", sshc.Key)
|
||||
assert.Equal(t, "key-file-password", sshc.KeyPassword)
|
||||
})
|
||||
|
||||
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())
|
||||
})
|
||||
}
|
||||
+205
-56
@@ -1,54 +1,60 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"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/shared"
|
||||
"github.com/sosedoff/pgweb/pkg/metrics"
|
||||
"github.com/sosedoff/pgweb/pkg/queries"
|
||||
"github.com/sosedoff/pgweb/pkg/util"
|
||||
"go.balki.me/anyhttp"
|
||||
)
|
||||
|
||||
var options command.Options
|
||||
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.
|
||||
--------------------------------------------------------------------------------`
|
||||
)
|
||||
|
||||
func init() {
|
||||
logger = logrus.New()
|
||||
}
|
||||
|
||||
func exitWithMessage(message string) {
|
||||
fmt.Println("Error:", message)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func initClientUsingBookmark(bookmarkPath, bookmarkName string) (*client.Client, error) {
|
||||
bookmark, err := bookmarks.GetBookmark(bookmarkPath, bookmarkName)
|
||||
func initClientUsingBookmark(baseDir, bookmarkName string) (*client.Client, error) {
|
||||
manager := bookmarks.NewManager(baseDir)
|
||||
bookmark, err := manager.Get(bookmarkName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
opt := bookmark.ConvertToOptions()
|
||||
var connStr string
|
||||
|
||||
if opt.Url != "" { // if the bookmark has url set, use it
|
||||
connStr = opt.Url
|
||||
} else {
|
||||
connStr, err = connection.BuildString(opt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error building connection string: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
var ssh *shared.SSHInfo
|
||||
if !bookmark.SSHInfoIsEmpty() {
|
||||
ssh = &bookmark.Ssh
|
||||
}
|
||||
|
||||
return client.NewFromUrl(connStr, ssh)
|
||||
return client.NewFromBookmark(bookmark)
|
||||
}
|
||||
|
||||
func initClient() {
|
||||
@@ -60,23 +66,33 @@ func initClient() {
|
||||
var err error
|
||||
|
||||
if options.Bookmark != "" {
|
||||
cl, err = initClientUsingBookmark(bookmarks.Path(options.BookmarksDir), 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("Opening database connection using string:", cl.ConnectionString)
|
||||
}
|
||||
|
||||
retryCount := command.Opts.RetryCount
|
||||
retryDelay := time.Second * time.Duration(command.Opts.RetryDelay)
|
||||
|
||||
fmt.Println("Connecting to server...")
|
||||
err = cl.Test()
|
||||
abort, err := testClient(cl, int(retryCount), retryDelay)
|
||||
if err != nil {
|
||||
if abort {
|
||||
exitWithMessage(err.Error())
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if !command.Opts.Sessions {
|
||||
fmt.Printf("Connected to %s\n", cl.ServerVersionInfo())
|
||||
}
|
||||
|
||||
fmt.Println("Checking database objects...")
|
||||
@@ -89,47 +105,101 @@ func initClient() {
|
||||
}
|
||||
|
||||
func initOptions() {
|
||||
err := command.ParseOptions()
|
||||
opts, err := command.ParseOptions(os.Args)
|
||||
if err != nil {
|
||||
switch err.(type) {
|
||||
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)
|
||||
}
|
||||
|
||||
options = command.Opts
|
||||
command.Opts = opts
|
||||
options = opts
|
||||
|
||||
if options.Version {
|
||||
printVersion()
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if options.ReadOnly {
|
||||
msg := `------------------------------------------------------
|
||||
SECURITY WARNING: You are running pgweb in read-only mode.
|
||||
This mode is designed for environments where users could potentially delete / change data.
|
||||
For proper read-only access please follow postgresql role management documentation.
|
||||
------------------------------------------------------`
|
||||
fmt.Println(msg)
|
||||
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 printVersion() {
|
||||
str := fmt.Sprintf("Pgweb v%s", command.VERSION)
|
||||
if command.GitCommit != "" {
|
||||
str += fmt.Sprintf(" (git: %s)", command.GitCommit)
|
||||
func configureLocalQueryStore() {
|
||||
if options.Sessions || options.QueriesDir == "" {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println(str)
|
||||
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 startServer() {
|
||||
router := gin.Default()
|
||||
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() *anyhttp.ServerCtx {
|
||||
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 != "" {
|
||||
@@ -137,26 +207,43 @@ func startServer() {
|
||||
router.Use(gin.BasicAuth(auth))
|
||||
}
|
||||
|
||||
api.SetLogger(logger)
|
||||
api.SetupRoutes(router)
|
||||
api.SetupMetrics(router)
|
||||
|
||||
fmt.Println("Starting server...")
|
||||
go func() {
|
||||
err := router.Run(fmt.Sprintf("%v:%v", options.HttpHost, options.HttpPort))
|
||||
address := fmt.Sprintf("%v:%v", options.HTTPHost, options.HTTPPort)
|
||||
if options.HTTPAddr != "" {
|
||||
address = options.HTTPAddr
|
||||
}
|
||||
|
||||
metrics.SetHealthy(true)
|
||||
|
||||
ctx, err := anyhttp.Serve(address, router.Handler())
|
||||
if err != nil {
|
||||
fmt.Println("Cant start server:", err)
|
||||
fmt.Println("Can't start server:", err)
|
||||
if strings.Contains(err.Error(), "address already in use") {
|
||||
openPage()
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
return ctx
|
||||
}
|
||||
|
||||
func handleSignals() {
|
||||
c := make(chan os.Signal, 1)
|
||||
signal.Notify(c, os.Interrupt, os.Kill)
|
||||
<-c
|
||||
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 openPage() {
|
||||
url := fmt.Sprintf("http://%v:%v/%s", options.HttpHost, options.HttpPort, options.Prefix)
|
||||
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 {
|
||||
@@ -168,7 +255,43 @@ func openPage() {
|
||||
return
|
||||
}
|
||||
|
||||
exec.Command("open", url).Output()
|
||||
_, err = exec.Command("open", url).Output()
|
||||
if err != nil {
|
||||
fmt.Println("Unable to auto-open pgweb URL:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// testClient attempts to establish a database connection until it succeeds or
|
||||
// give up after certain number of retries. Retries only available when database
|
||||
// name or a connection string is provided.
|
||||
func testClient(cl *client.Client, retryCount int, retryDelay time.Duration) (abort bool, err error) {
|
||||
usingDefaultDB := command.Opts.DbName == "" && command.Opts.URL == ""
|
||||
|
||||
for {
|
||||
err = cl.Test()
|
||||
if err == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Continue normal start up if can't connect locally without database details.
|
||||
if usingDefaultDB {
|
||||
if errors.Is(err, client.ErrConnectionRefused) ||
|
||||
errors.Is(err, client.ErrAuthFailed) ||
|
||||
errors.Is(err, client.ErrDatabaseNotExist) {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
// Only retry if can't establish connection to the server.
|
||||
if errors.Is(err, client.ErrConnectionRefused) && retryCount > 0 {
|
||||
fmt.Printf("Connection error: %v, retrying in %v (%d remaining)\n", err, retryDelay, retryCount)
|
||||
retryCount--
|
||||
<-time.After(retryDelay)
|
||||
continue
|
||||
}
|
||||
|
||||
return true, err
|
||||
}
|
||||
}
|
||||
|
||||
func Run() {
|
||||
@@ -188,7 +311,33 @@ func Run() {
|
||||
util.StartProfiler()
|
||||
}
|
||||
|
||||
startServer()
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
|
||||
// Start a separate metrics http server. If metrics addr is not provided, we
|
||||
// add the metrics endpoint in the existing application server (see api.go).
|
||||
if options.MetricsEnabled && options.MetricsAddr != "" {
|
||||
go startMetricsServer()
|
||||
}
|
||||
|
||||
serverCtx := startServer()
|
||||
|
||||
openPage()
|
||||
handleSignals()
|
||||
|
||||
c := make(chan os.Signal, 1)
|
||||
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
||||
select {
|
||||
case doneErr := <-serverCtx.Done:
|
||||
logger.Infoln("idle server. shutting down error: ", doneErr)
|
||||
case <-c:
|
||||
logger.Infoln("received signal. shutting down")
|
||||
serverCtx.Shutdown(context.TODO())
|
||||
}
|
||||
}
|
||||
|
||||
+337
-40
@@ -1,14 +1,20 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
neturl "net/url"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
"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"
|
||||
@@ -16,23 +22,32 @@ import (
|
||||
"github.com/sosedoff/pgweb/pkg/statements"
|
||||
)
|
||||
|
||||
var (
|
||||
regexErrAuthFailed = regexp.MustCompile(`(authentication failed|role "(.*)" does not exist)`)
|
||||
regexErrConnectionRefused = regexp.MustCompile(`(connection|actively) refused`)
|
||||
regexErrDatabaseNotExist = regexp.MustCompile(`database "(.*)" does not exist`)
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAuthFailed = errors.New("authentication failed")
|
||||
ErrConnectionRefused = errors.New("connection refused")
|
||||
ErrDatabaseNotExist = errors.New("database does not exist")
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
db *sqlx.DB
|
||||
tunnel *Tunnel
|
||||
serverVersion string
|
||||
serverType string
|
||||
lastQueryTime time.Time
|
||||
queryTimeout time.Duration
|
||||
readonly bool
|
||||
closed bool
|
||||
External bool `json:"external"`
|
||||
History []history.Record `json:"history"`
|
||||
ConnectionString string `json:"connection_string"`
|
||||
}
|
||||
|
||||
// Struct to hold table rows browsing options
|
||||
type 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)
|
||||
}
|
||||
|
||||
func getSchemaAndTable(str string) (string, string) {
|
||||
chunks := strings.Split(str, ".")
|
||||
if len(chunks) == 1 {
|
||||
@@ -42,7 +57,7 @@ func getSchemaAndTable(str string) (string, string) {
|
||||
}
|
||||
|
||||
func New() (*Client, error) {
|
||||
str, err := connection.BuildString(command.Opts)
|
||||
str, err := connection.BuildStringFromOptions(command.Opts)
|
||||
|
||||
if command.Opts.Debug && str != "" {
|
||||
fmt.Println("Creating a new client for:", str)
|
||||
@@ -63,19 +78,25 @@ func New() (*Client, error) {
|
||||
History: history.New(),
|
||||
}
|
||||
|
||||
client.setServerVersion()
|
||||
client.init()
|
||||
return &client, nil
|
||||
}
|
||||
|
||||
func NewFromUrl(url string, sshInfo *shared.SSHInfo) (*Client, error) {
|
||||
var tunnel *Tunnel
|
||||
var (
|
||||
tunnel *Tunnel
|
||||
err error
|
||||
)
|
||||
|
||||
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)
|
||||
tunnel, err = NewTunnel(sshInfo, url)
|
||||
if err != nil {
|
||||
tunnel.Close()
|
||||
return nil, err
|
||||
@@ -103,6 +124,11 @@ func NewFromUrl(url string, sshInfo *shared.SSHInfo) (*Client, error) {
|
||||
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
|
||||
@@ -111,14 +137,56 @@ func NewFromUrl(url string, sshInfo *shared.SSHInfo) (*Client, error) {
|
||||
client := Client{
|
||||
db: db,
|
||||
tunnel: tunnel,
|
||||
serverType: postgresType,
|
||||
ConnectionString: url,
|
||||
History: history.New(),
|
||||
}
|
||||
|
||||
client.setServerVersion()
|
||||
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
|
||||
}
|
||||
|
||||
client, err := NewFromUrl(connStr, sshInfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if bookmark.ReadOnly {
|
||||
client.readonly = true
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -126,15 +194,74 @@ func (client *Client) setServerVersion() {
|
||||
}
|
||||
|
||||
version := res.Rows[0][0].(string)
|
||||
client.serverVersion = strings.Split(version, " ")[1]
|
||||
match, serverType, serverVersion := detectServerTypeAndVersion(version)
|
||||
if match {
|
||||
client.serverType = serverType
|
||||
client.serverVersion = serverVersion
|
||||
}
|
||||
}
|
||||
|
||||
func (client *Client) Test() error {
|
||||
return client.db.Ping()
|
||||
// NOTE: This is a different timeout defined in CLI OpenTimeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := client.db.PingContext(ctx)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
errMsg := err.Error()
|
||||
|
||||
if regexErrConnectionRefused.MatchString(errMsg) {
|
||||
return ErrConnectionRefused
|
||||
}
|
||||
if regexErrAuthFailed.MatchString(errMsg) {
|
||||
return ErrAuthFailed
|
||||
}
|
||||
if regexErrDatabaseNotExist.MatchString(errMsg) {
|
||||
return ErrDatabaseNotExist
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
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) {
|
||||
return client.query(statements.Info)
|
||||
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) {
|
||||
@@ -158,6 +285,10 @@ 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)
|
||||
@@ -185,9 +316,34 @@ func (client *Client) TableRows(table string, opts RowsOptions) (*Result, error)
|
||||
return client.query(sql)
|
||||
}
|
||||
|
||||
func (client *Client) TableRowsCount(table string, opts RowsOptions) (*Result, error) {
|
||||
func (client *Client) EstimatedTableRowsCount(table string, opts RowsOptions) (*Result, error) {
|
||||
schema, table := getSchemaAndTable(table)
|
||||
sql := fmt.Sprintf(`SELECT COUNT(1) FROM "%s"."%s"`, schema, 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)
|
||||
@@ -197,7 +353,11 @@ func (client *Client) TableRowsCount(table string, opts RowsOptions) (*Result, e
|
||||
}
|
||||
|
||||
func (client *Client) TableInfo(table string) (*Result, error) {
|
||||
return client.query(statements.TableInfo, table)
|
||||
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) {
|
||||
@@ -222,11 +382,21 @@ func (client *Client) TableConstraints(table string) (*Result, error) {
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (client *Client) TablesStats() (*Result, error) {
|
||||
return client.query(statements.TablesStats)
|
||||
}
|
||||
|
||||
func (client *Client) ServerSettings() (*Result, error) {
|
||||
return client.query(statements.Settings)
|
||||
}
|
||||
|
||||
// Returns all active queriers on the server
|
||||
func (client *Client) Activity() (*Result, error) {
|
||||
chunks := strings.Split(client.serverVersion, ".")
|
||||
version := strings.Join(chunks[0:2], ".")
|
||||
if client.serverType == cockroachType {
|
||||
return client.query("SHOW QUERIES")
|
||||
}
|
||||
|
||||
version := getMajorMinorVersionString(client.serverVersion)
|
||||
query := statements.Activity[version]
|
||||
if query == "" {
|
||||
query = statements.Activity["default"]
|
||||
@@ -260,18 +430,28 @@ func (client *Client) SetReadOnlyMode() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (client *Client) query(query string, args ...interface{}) (*Result, error) {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
func (client *Client) ServerVersionInfo() string {
|
||||
return fmt.Sprintf("%s %s", client.serverType, client.serverVersion)
|
||||
}
|
||||
|
||||
action := strings.ToLower(strings.Split(query, " ")[0])
|
||||
if action == "update" || action == "delete" {
|
||||
res, err := client.db.Exec(query, args...)
|
||||
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
|
||||
}
|
||||
@@ -284,18 +464,60 @@ func (client *Client) query(query string, args ...interface{}) (*Result, error)
|
||||
result := Result{
|
||||
Columns: []string{"Rows Affected"},
|
||||
Rows: []Row{
|
||||
Row{affected},
|
||||
{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
|
||||
}
|
||||
|
||||
rows, err := client.db.Queryx(query, args...)
|
||||
if err != 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 || client.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()
|
||||
@@ -303,7 +525,7 @@ func (client *Client) query(query string, args ...interface{}) (*Result, error)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Make sure to never return null colums
|
||||
// Make sure to never return null columns
|
||||
if cols == nil {
|
||||
cols = []string{}
|
||||
}
|
||||
@@ -333,13 +555,29 @@ func (client *Client) query(query string, args ...interface{}) (*Result, error)
|
||||
}
|
||||
}
|
||||
|
||||
result.PrepareBigints()
|
||||
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
|
||||
client.tunnel = nil
|
||||
}()
|
||||
|
||||
if client.tunnel != nil {
|
||||
client.tunnel.Close()
|
||||
}
|
||||
@@ -351,6 +589,24 @@ func (client *Client) Close() error {
|
||||
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)
|
||||
@@ -381,3 +637,44 @@ func (client *Client) hasHistoryRecord(query string) bool {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
+517
-117
@@ -2,12 +2,19 @@ package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/sosedoff/pgweb/pkg/command"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -22,15 +29,37 @@ var (
|
||||
|
||||
func mapKeys(data map[string]*Objects) []string {
|
||||
result := []string{}
|
||||
for k, _ := range data {
|
||||
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)
|
||||
if _, err := fmt.Sscanf(os.Getenv("PGVERSION"), "%d.%d", &major, &minor); err != nil {
|
||||
log.Println("[warn] unable to read value of PGVERSION env var")
|
||||
}
|
||||
return major, minor
|
||||
}
|
||||
|
||||
@@ -43,6 +72,11 @@ func getVar(name, def string) string {
|
||||
}
|
||||
|
||||
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")
|
||||
@@ -69,6 +103,9 @@ func onWindows() bool {
|
||||
}
|
||||
|
||||
func setup() {
|
||||
// No pretty JSON for tests
|
||||
command.Opts.DisablePrettyJSON = true
|
||||
|
||||
out, err := exec.Command(
|
||||
testCommands["createdb"],
|
||||
"-U", serverUser,
|
||||
@@ -106,12 +143,12 @@ func setupClient() {
|
||||
|
||||
func teardownClient() {
|
||||
if testClient != nil {
|
||||
testClient.db.Close()
|
||||
testClient.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func teardown() {
|
||||
_, err := exec.Command(
|
||||
func teardown(t *testing.T, allowFail bool) {
|
||||
output, err := exec.Command(
|
||||
testCommands["dropdb"],
|
||||
"-U", serverUser,
|
||||
"-h", serverHost,
|
||||
@@ -119,62 +156,169 @@ func teardown() {
|
||||
serverDatabase,
|
||||
).CombinedOutput()
|
||||
|
||||
if err != nil {
|
||||
fmt.Println("Teardown error:", err)
|
||||
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 test_NewClientFromUrl(t *testing.T) {
|
||||
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)
|
||||
|
||||
if err != nil {
|
||||
defer client.Close()
|
||||
}
|
||||
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, url, client.ConnectionString)
|
||||
}
|
||||
assert.NoError(t, client.Close())
|
||||
})
|
||||
|
||||
func test_NewClientFromUrl2(t *testing.T) {
|
||||
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)
|
||||
|
||||
if err != nil {
|
||||
defer client.Close()
|
||||
}
|
||||
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, url, client.ConnectionString)
|
||||
assert.NoError(t, client.Close())
|
||||
})
|
||||
}
|
||||
|
||||
func test_Test(t *testing.T) {
|
||||
assert.Equal(t, nil, testClient.Test())
|
||||
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 test_Info(t *testing.T) {
|
||||
func testTest(t *testing.T) {
|
||||
examples := []struct {
|
||||
name string
|
||||
input string
|
||||
err error
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
input: fmt.Sprintf("postgres://%s@%s:%s/%s?sslmode=disable", serverUser, serverHost, serverPort, serverDatabase),
|
||||
err: nil,
|
||||
},
|
||||
{
|
||||
name: "connection refused",
|
||||
input: "postgresql://localhost:5433/dbname",
|
||||
err: ErrConnectionRefused,
|
||||
},
|
||||
{
|
||||
name: "invalid user",
|
||||
input: fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable", "foo", serverPassword, serverHost, serverPort, serverDatabase),
|
||||
err: ErrAuthFailed,
|
||||
},
|
||||
{
|
||||
name: "invalid password",
|
||||
input: fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable", serverUser, "foo", serverHost, serverPort, serverDatabase),
|
||||
err: ErrAuthFailed,
|
||||
},
|
||||
{
|
||||
name: "invalid database",
|
||||
input: fmt.Sprintf("postgres://%s@%s:%s/%s?sslmode=disable", serverUser, serverHost, serverPort, "foo"),
|
||||
err: ErrDatabaseNotExist,
|
||||
},
|
||||
}
|
||||
|
||||
for _, ex := range examples {
|
||||
t.Run(ex.name, func(t *testing.T) {
|
||||
conn, err := NewFromUrl(ex.input, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, ex.err, conn.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)
|
||||
})
|
||||
|
||||
assert.Equal(t, nil, err)
|
||||
assert.NotEqual(t, nil, res)
|
||||
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 test_Activity(t *testing.T) {
|
||||
func testActivity(t *testing.T) {
|
||||
expected := []string{"datid", "pid", "query", "query_start", "state", "client_addr"}
|
||||
|
||||
res, err := testClient.Activity()
|
||||
|
||||
assert.Equal(t, nil, err)
|
||||
assert.NotEqual(t, nil, res)
|
||||
assert.NoError(t, err)
|
||||
assertMatches(t, expected, res.Columns)
|
||||
}
|
||||
|
||||
func test_Databases(t *testing.T) {
|
||||
func testDatabases(t *testing.T) {
|
||||
res, err := testClient.Databases()
|
||||
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Contains(t, res, "booktown")
|
||||
assert.Contains(t, res, "postgres")
|
||||
assert.NoError(t, err)
|
||||
assertMatches(t, []string{"booktown", "postgres"}, res)
|
||||
}
|
||||
|
||||
func test_Objects(t *testing.T) {
|
||||
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)
|
||||
|
||||
@@ -205,24 +349,50 @@ func test_Objects(t *testing.T) {
|
||||
"text_sorting",
|
||||
}
|
||||
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, []string{"schema", "name", "type", "owner", "comment"}, res.Columns)
|
||||
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, objects["public"].Tables)
|
||||
assert.Equal(t, []string{"recent_shipments", "stock_view"}, objects["public"].Views)
|
||||
assert.Equal(t, []string{"author_ids", "book_ids", "shipments_ship_id_seq", "subject_ids"}, objects["public"].Sequences)
|
||||
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"}, objects["public"].MaterializedViews)
|
||||
assert.Equal(t, []string{"m_stock_view"}, objectNames(objects["public"].MaterializedViews))
|
||||
} else {
|
||||
t.Logf("Skipping materialized view on %d.%d\n", major, minor)
|
||||
}
|
||||
}
|
||||
|
||||
func test_Table(t *testing.T) {
|
||||
res, err := testClient.Table("books")
|
||||
|
||||
func testTable(t *testing.T) {
|
||||
columns := []string{
|
||||
"column_name",
|
||||
"data_type",
|
||||
@@ -233,124 +403,346 @@ func test_Table(t *testing.T) {
|
||||
"comment",
|
||||
}
|
||||
|
||||
assert.Equal(t, nil, err)
|
||||
res, err := testClient.Table("books")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, columns, res.Columns)
|
||||
assert.Equal(t, 4, len(res.Rows))
|
||||
}
|
||||
|
||||
func test_TableRows(t *testing.T) {
|
||||
func testTableRows(t *testing.T) {
|
||||
res, err := testClient.TableRows("books", RowsOptions{})
|
||||
|
||||
assert.Equal(t, nil, err)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 4, len(res.Columns))
|
||||
assert.Equal(t, 15, len(res.Rows))
|
||||
}
|
||||
|
||||
func test_TableInfo(t *testing.T) {
|
||||
func testTableInfo(t *testing.T) {
|
||||
res, err := testClient.TableInfo("books")
|
||||
|
||||
assert.Equal(t, nil, err)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 4, len(res.Columns))
|
||||
assert.Equal(t, 1, len(res.Rows))
|
||||
}
|
||||
|
||||
func test_TableIndexes(t *testing.T) {
|
||||
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.Equal(t, nil, err)
|
||||
assert.Equal(t, 2, len(res.Columns))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []string{"index_name", "index_size", "index_definition"}, res.Columns)
|
||||
assert.Equal(t, 2, len(res.Rows))
|
||||
}
|
||||
|
||||
func test_TableConstraints(t *testing.T) {
|
||||
func testTableConstraints(t *testing.T) {
|
||||
res, err := testClient.TableConstraints("editions")
|
||||
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, 1, len(res.Columns))
|
||||
assert.Equal(t, 2, len(res.Rows))
|
||||
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 test_Query(t *testing.T) {
|
||||
res, err := testClient.Query("SELECT * FROM books")
|
||||
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');`)
|
||||
|
||||
assert.Equal(t, nil, err)
|
||||
_, 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))
|
||||
}
|
||||
})
|
||||
|
||||
func test_QueryError(t *testing.T) {
|
||||
t.Run("error", func(t *testing.T) {
|
||||
res, err := testClient.Query("SELCT * FROM books")
|
||||
|
||||
assert.NotEqual(t, nil, err)
|
||||
assert.NotNil(t, err)
|
||||
assert.Equal(t, "pq: syntax error at or near \"SELCT\"", err.Error())
|
||||
assert.Equal(t, true, res == nil)
|
||||
}
|
||||
assert.Nil(t, res)
|
||||
})
|
||||
|
||||
func test_QueryInvalidTable(t *testing.T) {
|
||||
t.Run("invalid table", func(t *testing.T) {
|
||||
res, err := testClient.Query("SELECT * FROM books2")
|
||||
|
||||
assert.NotEqual(t, nil, err)
|
||||
assert.NotNil(t, err)
|
||||
assert.Equal(t, "pq: relation \"books2\" does not exist", err.Error())
|
||||
assert.Equal(t, true, res == nil)
|
||||
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 test_TableRowsOrderEscape(t *testing.T) {
|
||||
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.Equal(t, nil, err)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 2, len(rows.Rows))
|
||||
|
||||
rows, err = testClient.TableRows("dummies", RowsOptions{SortColumn: "isdummy"})
|
||||
assert.NotEqual(t, nil, err)
|
||||
assert.NotNil(t, err)
|
||||
assert.Equal(t, `pq: column "isdummy" does not exist`, err.Error())
|
||||
assert.Equal(t, true, rows == nil)
|
||||
assert.Nil(t, rows)
|
||||
}
|
||||
|
||||
func test_ResultCsv(t *testing.T) {
|
||||
res, _ := testClient.Query("SELECT * FROM books ORDER BY id ASC LIMIT 1")
|
||||
csv := res.CSV()
|
||||
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"
|
||||
|
||||
assert.Equal(t, expected, string(csv))
|
||||
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 test_History(t *testing.T) {
|
||||
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.Equal(t, nil, err)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "SELECT * FROM books WHERE id = 12345", query)
|
||||
}
|
||||
})
|
||||
|
||||
func test_HistoryError(t *testing.T) {
|
||||
t.Run("failed query", func(t *testing.T) {
|
||||
_, err := testClient.Query("SELECT * FROM books123")
|
||||
query := testClient.History[len(testClient.History)-1].Query
|
||||
|
||||
assert.NotEqual(t, nil, err)
|
||||
assert.NotNil(t, err)
|
||||
assert.NotEqual(t, "SELECT * FROM books123", query)
|
||||
}
|
||||
})
|
||||
|
||||
func test_HistoryUniqueness(t *testing.T) {
|
||||
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)
|
||||
|
||||
client.Query("SELECT * FROM books WHERE id = 1")
|
||||
client.Query("SELECT * FROM books WHERE id = 1")
|
||||
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 test_ReadOnlyMode(t *testing.T) {
|
||||
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.Equal(t, nil, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = client.Query("CREATE TABLE foobar(id integer);")
|
||||
_, err = client.Query("\nCREATE TABLE foobar(id integer);\n")
|
||||
assert.NotNil(t, err)
|
||||
assert.Contains(t, err.Error(), "in a read-only transaction")
|
||||
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)
|
||||
|
||||
t.Run("with local readonly flag", func(t *testing.T) {
|
||||
command.Opts.ReadOnly = false
|
||||
client.readonly = true
|
||||
|
||||
_, err := client.Query("INSERT INTO foobar(id) VALUES(1)")
|
||||
assert.Error(t, err, "query contains keywords not allowed in read-only mode")
|
||||
})
|
||||
}
|
||||
|
||||
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 testServerSettings(t *testing.T) {
|
||||
expectedColumns := []string{
|
||||
"name",
|
||||
"setting",
|
||||
"unit",
|
||||
"category",
|
||||
"short_desc",
|
||||
"extra_desc",
|
||||
"context",
|
||||
"vartype",
|
||||
"source",
|
||||
"min_val",
|
||||
"max_val",
|
||||
"enumvals",
|
||||
"boot_val",
|
||||
"reset_val",
|
||||
"sourcefile",
|
||||
"sourceline",
|
||||
"pending_restart",
|
||||
}
|
||||
|
||||
result, err := testClient.ServerSettings()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expectedColumns, result.Columns)
|
||||
}
|
||||
|
||||
func TestAll(t *testing.T) {
|
||||
@@ -361,31 +753,39 @@ func TestAll(t *testing.T) {
|
||||
|
||||
initVars()
|
||||
setupCommands()
|
||||
teardown()
|
||||
teardown(t, false)
|
||||
setup()
|
||||
setupClient()
|
||||
|
||||
test_NewClientFromUrl(t)
|
||||
test_Test(t)
|
||||
test_Info(t)
|
||||
test_Activity(t)
|
||||
test_Databases(t)
|
||||
test_Objects(t)
|
||||
test_Table(t)
|
||||
test_TableRows(t)
|
||||
test_TableInfo(t)
|
||||
test_TableIndexes(t)
|
||||
test_TableConstraints(t)
|
||||
test_Query(t)
|
||||
test_QueryError(t)
|
||||
test_QueryInvalidTable(t)
|
||||
test_TableRowsOrderEscape(t)
|
||||
test_ResultCsv(t)
|
||||
test_History(t)
|
||||
test_HistoryUniqueness(t)
|
||||
test_HistoryError(t)
|
||||
test_ReadOnlyMode(t)
|
||||
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)
|
||||
testServerSettings(t)
|
||||
|
||||
teardownClient()
|
||||
teardown()
|
||||
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 (
|
||||
// BinaryCodec sets the 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)
|
||||
}
|
||||
+112
-45
@@ -5,69 +5,124 @@ import (
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"log"
|
||||
"math"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/sosedoff/pgweb/pkg/command"
|
||||
)
|
||||
|
||||
type Row []interface{}
|
||||
const (
|
||||
ObjTypeTable = "table"
|
||||
ObjTypeView = "view"
|
||||
ObjTypeMaterializedView = "materialized_view"
|
||||
ObjTypeSequence = "sequence"
|
||||
ObjTypeFunction = "function"
|
||||
)
|
||||
|
||||
type Pagination struct {
|
||||
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"`
|
||||
}
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Result struct {
|
||||
Pagination *Pagination `json:"pagination,omitempty"`
|
||||
Columns []string `json:"columns"`
|
||||
Rows []Row `json:"rows"`
|
||||
}
|
||||
Stats *ResultStats `json:"stats,omitempty"`
|
||||
}
|
||||
|
||||
type Objects struct {
|
||||
Tables []string `json:"table"`
|
||||
Views []string `json:"view"`
|
||||
MaterializedViews []string `json:"materialized_view"`
|
||||
Sequences []string `json:"sequence"`
|
||||
}
|
||||
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) PrepareBigints() {
|
||||
func (res *Result) PostProcess() {
|
||||
for i, row := range res.Rows {
|
||||
for j, col := range row {
|
||||
if col == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch reflect.TypeOf(col).Kind() {
|
||||
case reflect.Int64:
|
||||
val := col.(int64)
|
||||
switch val := col.(type) {
|
||||
case int64:
|
||||
if val < -9007199254740991 || val > 9007199254740991 {
|
||||
res.Rows[i][j] = strconv.FormatInt(col.(int64), 10)
|
||||
}
|
||||
case reflect.Float64:
|
||||
val := col.(float64)
|
||||
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{} {
|
||||
var items []map[string]interface{}
|
||||
items := make([]map[string]interface{}, len(res.Rows))
|
||||
|
||||
for _, row := range res.Rows {
|
||||
for rowIdx, row := range res.Rows {
|
||||
item := make(map[string]interface{})
|
||||
|
||||
for i, c := range res.Columns {
|
||||
item[c] = row[i]
|
||||
}
|
||||
|
||||
items = append(items, item)
|
||||
items[rowIdx] = item
|
||||
}
|
||||
|
||||
return items
|
||||
@@ -77,26 +132,25 @@ func (res *Result) CSV() []byte {
|
||||
buff := &bytes.Buffer{}
|
||||
writer := csv.NewWriter(buff)
|
||||
|
||||
writer.Write(res.Columns)
|
||||
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 {
|
||||
if item != nil {
|
||||
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)
|
||||
}
|
||||
} else {
|
||||
record[i] = ""
|
||||
}
|
||||
}
|
||||
|
||||
err := writer.Write(record)
|
||||
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
break
|
||||
@@ -108,7 +162,14 @@ func (res *Result) CSV() []byte {
|
||||
}
|
||||
|
||||
func (res *Result) JSON() []byte {
|
||||
data, _ := json.Marshal(res.Format())
|
||||
var data []byte
|
||||
|
||||
if command.Opts.DisablePrettyJSON {
|
||||
data, _ = json.Marshal(res.Format())
|
||||
} else {
|
||||
data, _ = json.MarshalIndent(res.Format(), "", " ")
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -116,28 +177,34 @@ func ObjectsFromResult(res *Result) map[string]*Objects {
|
||||
objects := map[string]*Objects{}
|
||||
|
||||
for _, row := range res.Rows {
|
||||
schema := row[0].(string)
|
||||
name := row[1].(string)
|
||||
object_type := row[2].(string)
|
||||
oid := row[0].(string)
|
||||
schema := row[1].(string)
|
||||
name := row[2].(string)
|
||||
objectType := row[3].(string)
|
||||
|
||||
if objects[schema] == nil {
|
||||
objects[schema] = &Objects{
|
||||
Tables: []string{},
|
||||
Views: []string{},
|
||||
MaterializedViews: []string{},
|
||||
Sequences: []string{},
|
||||
Tables: []Object{},
|
||||
Views: []Object{},
|
||||
MaterializedViews: []Object{},
|
||||
Functions: []Object{},
|
||||
Sequences: []Object{},
|
||||
}
|
||||
}
|
||||
|
||||
switch object_type {
|
||||
case "table":
|
||||
objects[schema].Tables = append(objects[schema].Tables, name)
|
||||
case "view":
|
||||
objects[schema].Views = append(objects[schema].Views, name)
|
||||
case "materialized_view":
|
||||
objects[schema].MaterializedViews = append(objects[schema].MaterializedViews, name)
|
||||
case "sequence":
|
||||
objects[schema].Sequences = append(objects[schema].Sequences, name)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+84
-18
@@ -2,53 +2,78 @@ package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sosedoff/pgweb/pkg/command"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_PrepareBigints(t *testing.T) {
|
||||
func TestPostProcess(t *testing.T) {
|
||||
t.Run("large numbers", func(t *testing.T) {
|
||||
result := Result{
|
||||
Columns: []string{"value"},
|
||||
Rows: []Row{
|
||||
Row{int(1234)},
|
||||
Row{int64(9223372036854775807)},
|
||||
Row{int64(-9223372036854775808)},
|
||||
Row{float64(9223372036854775808.9223372036854775808)},
|
||||
Row{float64(999999999999999.9)},
|
||||
{int(1234)},
|
||||
{int64(9223372036854775807)},
|
||||
{int64(-9223372036854775808)},
|
||||
{float64(9223372036854775808.9223372036854775808)},
|
||||
{float64(999999999999999.9)},
|
||||
},
|
||||
}
|
||||
|
||||
result.PrepareBigints()
|
||||
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])
|
||||
}
|
||||
})
|
||||
|
||||
func Test_CSV(t *testing.T) {
|
||||
t.Run("binary encoding", func(t *testing.T) {
|
||||
result := Result{
|
||||
Columns: []string{"id", "name", "email"},
|
||||
Columns: []string{"data"},
|
||||
Rows: []Row{
|
||||
Row{1, "John", "john@example.com"},
|
||||
Row{2, "Bob", "bob@example.com"},
|
||||
{"text value"},
|
||||
{"text with symbols !@#$%"},
|
||||
{string([]byte{10, 11, 12, 13})},
|
||||
},
|
||||
}
|
||||
|
||||
expected := "id,name,email\n1,John,john@example.com\n2,Bob,bob@example.com\n"
|
||||
output := string(result.CSV())
|
||||
result.PostProcess()
|
||||
|
||||
assert.Equal(t, expected, output)
|
||||
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 Test_JSON(t *testing.T) {
|
||||
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{
|
||||
Row{1, "John", "john@example.com"},
|
||||
Row{2, "Bob", "bob@example.com"},
|
||||
{1, "John", "john@example.com"},
|
||||
{2, "Bob", "bob@example.com"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -70,4 +95,45 @@ func Test_JSON(t *testing.T) {
|
||||
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())
|
||||
}
|
||||
|
||||
+63
-18
@@ -1,16 +1,19 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ScaleFT/sshkeys"
|
||||
"golang.org/x/crypto/ssh"
|
||||
|
||||
"github.com/sosedoff/pgweb/pkg/connection"
|
||||
@@ -18,10 +21,11 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
PORT_START = 29168
|
||||
PORT_LIMIT = 500
|
||||
portStart = 29168
|
||||
portLimit = 500
|
||||
)
|
||||
|
||||
// Tunnel represents the connection between local and remote server
|
||||
type Tunnel struct {
|
||||
TargetHost string
|
||||
TargetPort string
|
||||
@@ -32,8 +36,16 @@ type Tunnel struct {
|
||||
Listener *net.TCPListener
|
||||
}
|
||||
|
||||
func privateKeyPath() string {
|
||||
return os.Getenv("HOME") + "/.ssh/id_rsa"
|
||||
func defaultKeyPath() string {
|
||||
return filepath.Join(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 {
|
||||
@@ -41,31 +53,60 @@ func fileExists(path string) bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func parsePrivateKey(keyPath string) (ssh.Signer, error) {
|
||||
buff, err := ioutil.ReadFile(keyPath)
|
||||
func parsePrivateKey(keyPath string, keyPass string) (ssh.Signer, error) {
|
||||
buff, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ssh.ParsePrivateKey(buff)
|
||||
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{}
|
||||
|
||||
keyPath := privateKeyPath()
|
||||
if fileExists(keyPath) {
|
||||
key, err := parsePrivateKey(keyPath)
|
||||
// Try to use user-provided key, fallback to system default key
|
||||
keyPath := info.Key
|
||||
if keyPath == "" {
|
||||
keyPath = defaultKeyPath()
|
||||
} else {
|
||||
keyPath = expandKeyPath(keyPath)
|
||||
}
|
||||
|
||||
if !fileExists(keyPath) {
|
||||
return nil, fmt.Errorf("ssh public key not found at path %q", 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))
|
||||
}
|
||||
|
||||
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 &ssh.ClientConfig{User: info.User, Auth: methods}, nil
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (tunnel *Tunnel) sshEndpoint() string {
|
||||
@@ -89,16 +130,17 @@ func (tunnel *Tunnel) handleConnection(local net.Conn) {
|
||||
return
|
||||
}
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
wg := &sync.WaitGroup{}
|
||||
wg.Add(2)
|
||||
|
||||
go tunnel.copy(&wg, local, remote)
|
||||
go tunnel.copy(&wg, remote, local)
|
||||
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()
|
||||
@@ -109,6 +151,7 @@ func (tunnel *Tunnel) Close() {
|
||||
}
|
||||
}
|
||||
|
||||
// Configure establishes the tunnel between localhost and remote machine
|
||||
func (tunnel *Tunnel) Configure() error {
|
||||
config, err := makeConfig(tunnel.SSHInfo)
|
||||
if err != nil {
|
||||
@@ -131,6 +174,7 @@ func (tunnel *Tunnel) Configure() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start starts the connection handler loop
|
||||
func (tunnel *Tunnel) Start() {
|
||||
defer tunnel.Close()
|
||||
|
||||
@@ -144,13 +188,14 @@ func (tunnel *Tunnel) Start() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.AvailablePort(PORT_START, PORT_LIMIT)
|
||||
listenPort, err := connection.FindAvailablePort(portStart, portLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -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|UPDATE|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) //nolint
|
||||
if len(chunks) > 1 {
|
||||
fmt.Sscanf(chunks[1], "%d", &minor) //nolint
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
+215
-36
@@ -1,67 +1,246 @@
|
||||
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" default:"false"`
|
||||
Url string `long:"url" description:"Database connection string"`
|
||||
Host string `long:"host" description:"Server hostname or IP"`
|
||||
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"`
|
||||
Ssl string `long:"ssl" description:"SSL option"`
|
||||
HttpHost string `long:"bind" description:"HTTP server host" default:"localhost"`
|
||||
HttpPort uint `long:"listen" description:"HTTP server listen port" default:"8081"`
|
||||
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 time for connection, in seconds" default:"30"`
|
||||
RetryDelay uint `long:"open-retry-delay" description:"Number of seconds to wait before retrying the connection" default:"3"`
|
||||
RetryCount uint `long:"open-retry" description:"Number of times to retry establishing connection" default:"0"`
|
||||
HTTPAddr string `long:"address" description:"anyhttp address. See https://pkg.go.dev/go.balki.me/anyhttp#readme-address-syntax" default:""`
|
||||
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" default:"false"`
|
||||
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" default:"false"`
|
||||
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:""`
|
||||
BookmarksOnly bool `long:"bookmarks-only" description:"Allow only connections from bookmarks"`
|
||||
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
|
||||
|
||||
func ParseOptions() error {
|
||||
_, err := flags.ParseArgs(&Opts, os.Args)
|
||||
// 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("BOOKMARKS_ONLY") != "" {
|
||||
opts.BookmarksOnly = true
|
||||
}
|
||||
|
||||
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.HasSuffix(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")
|
||||
}
|
||||
}
|
||||
|
||||
if opts.BookmarksOnly {
|
||||
if opts.URL != "" {
|
||||
return opts, errors.New("--url not supported in bookmarks-only mode")
|
||||
}
|
||||
if opts.Host != "" && opts.Host != "localhost" {
|
||||
return opts, errors.New("--host not supported in bookmarks-only mode")
|
||||
}
|
||||
if opts.ConnectBackend != "" {
|
||||
return opts, errors.New("--connect-backend not supported in bookmarks-only mode")
|
||||
}
|
||||
}
|
||||
|
||||
homePath, err := homedir.Dir()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[WARN] can't 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
|
||||
}
|
||||
|
||||
if Opts.Url == "" {
|
||||
Opts.Url = os.Getenv("DATABASE_URL")
|
||||
}
|
||||
|
||||
if os.Getenv("SESSIONS") != "" {
|
||||
Opts.Sessions = true
|
||||
}
|
||||
|
||||
if os.Getenv("LOCK_SESSION") != "" {
|
||||
Opts.LockSession = true
|
||||
Opts.Sessions = false
|
||||
}
|
||||
|
||||
if Opts.Prefix != "" && !strings.Contains(Opts.Prefix, "/") {
|
||||
Opts.Prefix = Opts.Prefix + "/"
|
||||
}
|
||||
|
||||
if Opts.AuthUser == "" && os.Getenv("AUTH_USER") != "" {
|
||||
Opts.AuthUser = os.Getenv("AUTH_USER")
|
||||
}
|
||||
|
||||
if Opts.AuthPass == "" && os.Getenv("AUTH_PASS") != "" {
|
||||
Opts.AuthPass = os.Getenv("AUTH_PASS")
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
+85
-26
@@ -2,37 +2,96 @@ package command
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mitchellh/go-homedir"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_Options(t *testing.T) {
|
||||
err := ParseOptions()
|
||||
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)
|
||||
}
|
||||
|
||||
func Test_SessionsOption(t *testing.T) {
|
||||
oldargs := os.Args
|
||||
defer func() { os.Args = oldargs }()
|
||||
|
||||
os.Args = []string{"--sessions", "1"}
|
||||
assert.NoError(t, ParseOptions())
|
||||
assert.Equal(t, true, Opts.Sessions)
|
||||
}
|
||||
|
||||
func Test_PrefixOption(t *testing.T) {
|
||||
oldargs := os.Args
|
||||
defer func() { os.Args = oldargs }()
|
||||
|
||||
os.Args = []string{"--prefix", "pgweb"}
|
||||
assert.NoError(t, ParseOptions())
|
||||
assert.Equal(t, "pgweb/", Opts.Prefix)
|
||||
|
||||
os.Args = []string{"--prefix", "pgweb/"}
|
||||
assert.NoError(t, ParseOptions())
|
||||
assert.Equal(t, "pgweb/", Opts.Prefix)
|
||||
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)
|
||||
})
|
||||
|
||||
t.Run("bookmarks only mode", func(t *testing.T) {
|
||||
_, err := ParseOptions([]string{"--bookmarks-only"})
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = ParseOptions([]string{"--bookmarks-only", "--url", "test"})
|
||||
assert.EqualError(t, err, "--url not supported in bookmarks-only mode")
|
||||
|
||||
_, err = ParseOptions([]string{"--bookmarks-only", "--host", "test", "--port", "5432"})
|
||||
assert.EqualError(t, err, "--host not supported in bookmarks-only mode")
|
||||
|
||||
_, err = ParseOptions([]string{"--bookmarks-only", "--connect-backend", "test", "--sessions", "--connect-token", "token", "--url", "127.0.0.2"})
|
||||
assert.EqualError(t, err, "--connect-backend not supported in bookmarks-only mode")
|
||||
})
|
||||
}
|
||||
|
||||
+57
-1
@@ -1,8 +1,64 @@
|
||||
package command
|
||||
|
||||
const VERSION = "0.9.8"
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// Version is the current Pgweb application version
|
||||
Version = "0.17.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,92 @@
|
||||
package connect
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type Backend struct {
|
||||
Endpoint string
|
||||
Token string
|
||||
PassHeaders []string
|
||||
|
||||
logger *logrus.Logger
|
||||
}
|
||||
|
||||
func NewBackend(endpoint string, token string) Backend {
|
||||
return Backend{
|
||||
Endpoint: endpoint,
|
||||
Token: token,
|
||||
logger: logrus.StandardLogger(),
|
||||
}
|
||||
}
|
||||
|
||||
func (be *Backend) SetLogger(logger *logrus.Logger) {
|
||||
be.logger = logger
|
||||
}
|
||||
|
||||
func (be *Backend) SetPassHeaders(headers []string) {
|
||||
be.PassHeaders = headers
|
||||
}
|
||||
|
||||
func (be *Backend) FetchCredential(ctx context.Context, resource string, headers http.Header) (*Credential, error) {
|
||||
be.logger.WithField("resource", resource).Debug("fetching database credential")
|
||||
|
||||
request := Request{
|
||||
Resource: resource,
|
||||
Token: be.Token,
|
||||
Headers: map[string]string{},
|
||||
}
|
||||
|
||||
// Pass allow-listed client headers to the backend request
|
||||
for _, name := range be.PassHeaders {
|
||||
request.Headers[strings.ToLower(name)] = headers.Get(name)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
be.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 {
|
||||
be.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)
|
||||
|
||||
be.logger.
|
||||
WithField("resource", request.Resource).
|
||||
WithField("status", resp.StatusCode).
|
||||
Error(err)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cred := &Credential{}
|
||||
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,173 @@
|
||||
package connect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestBackendFetchCredential(t *testing.T) {
|
||||
examples := []struct {
|
||||
name string
|
||||
backend Backend
|
||||
resourceName string
|
||||
cred *Credential
|
||||
headers http.Header
|
||||
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"},
|
||||
},
|
||||
headers: http.Header{
|
||||
"X-Foo": []string{"bar"},
|
||||
},
|
||||
cred: &Credential{DatabaseURL: "postgres://hostname/bar"},
|
||||
},
|
||||
{
|
||||
name: "Success",
|
||||
backend: Backend{Endpoint: "http://localhost:5555/success"},
|
||||
cred: &Credential{DatabaseURL: "postgres://hostname/dbname"},
|
||||
},
|
||||
}
|
||||
|
||||
srvCtx, srvCancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer srvCancel()
|
||||
|
||||
startTestBackend(srvCtx, "localhost:5555")
|
||||
|
||||
for _, ex := range examples {
|
||||
ex.backend.logger = logrus.StandardLogger()
|
||||
|
||||
t.Run(ex.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if ex.ctx != nil {
|
||||
ctx, cancel = ex.ctx()
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
cred, err := ex.backend.FetchCredential(ctx, ex.resourceName, ex.headers)
|
||||
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 := Request{}
|
||||
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,20 @@
|
||||
package connect
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
errBackendConnectError = errors.New("unable to connect to the auth backend")
|
||||
errConnStringRequired = errors.New("connection string is required")
|
||||
)
|
||||
|
||||
// Request holds the resource request details
|
||||
type Request struct {
|
||||
Resource string `json:"resource"`
|
||||
Token string `json:"token"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
}
|
||||
|
||||
// Credential holds the database connection string
|
||||
type Credential struct {
|
||||
DatabaseURL string `json:"database_url"`
|
||||
}
|
||||
@@ -6,11 +6,21 @@ import (
|
||||
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 {
|
||||
@@ -22,44 +32,91 @@ func currentUser() (string, error) {
|
||||
return name, nil
|
||||
}
|
||||
|
||||
return "", errors.New("Unable to detect OS user")
|
||||
return "", errCantDetectUser
|
||||
}
|
||||
|
||||
func FormatUrl(opts command.Options) (string, error) {
|
||||
url := opts.Url
|
||||
// Check if connection url has a correct postgres prefix
|
||||
func hasValidPrefix(str string) bool {
|
||||
return strings.HasPrefix(str, "postgres://") || strings.HasPrefix(str, "postgresql://")
|
||||
}
|
||||
|
||||
// Make sure to only accept urls in a standard format
|
||||
if !strings.HasPrefix(url, "postgres://") && !strings.HasPrefix(url, "postgresql://") {
|
||||
return "", errors.New("Invalid URL. Valid format: postgres://user:password@host:port/db?sslmode=mode")
|
||||
// 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
|
||||
}
|
||||
|
||||
// Special handling for local connections
|
||||
if strings.Contains(url, "localhost") || strings.Contains(url, "127.0.0.1") {
|
||||
if !strings.Contains(url, "?sslmode") {
|
||||
if opts.Ssl == "" {
|
||||
url += fmt.Sprintf("?sslmode=%s", "disable")
|
||||
// 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 {
|
||||
url += fmt.Sprintf("?sslmode=%s", opts.Ssl)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Append sslmode parameter only if its defined as a flag and not present
|
||||
// in the connection string.
|
||||
if !strings.Contains(url, "?sslmode") && opts.Ssl != "" {
|
||||
url += fmt.Sprintf("?sslmode=%s", opts.Ssl)
|
||||
// Configure default connect timeout
|
||||
if opts.OpenTimeout > 0 {
|
||||
params["connect_timeout"] = strconv.Itoa(opts.OpenTimeout)
|
||||
}
|
||||
|
||||
return url, nil
|
||||
// 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 == ""
|
||||
return opts.Host == "" && opts.User == "" && opts.DbName == "" && opts.URL == ""
|
||||
}
|
||||
|
||||
func BuildString(opts command.Options) (string, error) {
|
||||
if opts.Url != "" {
|
||||
return FormatUrl(opts)
|
||||
// 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
|
||||
@@ -70,32 +127,71 @@ func BuildString(opts command.Options) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Disable ssl for localhost connections, most users have it disabled
|
||||
if opts.SSLMode != "" {
|
||||
query.Add("sslmode", opts.SSLMode)
|
||||
} else {
|
||||
if opts.Host == "localhost" || opts.Host == "127.0.0.1" {
|
||||
if opts.Ssl == "" {
|
||||
opts.Ssl = "disable"
|
||||
query.Add("sslmode", "disable")
|
||||
}
|
||||
}
|
||||
|
||||
url := "postgres://"
|
||||
|
||||
if opts.User != "" {
|
||||
url += opts.User
|
||||
if opts.SSLCert != "" {
|
||||
query.Add("sslcert", opts.SSLCert)
|
||||
}
|
||||
if opts.SSLKey != "" {
|
||||
query.Add("sslkey", opts.SSLKey)
|
||||
}
|
||||
if opts.SSLRootCert != "" {
|
||||
query.Add("sslrootcert", opts.SSLRootCert)
|
||||
}
|
||||
|
||||
if opts.Pass != "" {
|
||||
url += fmt.Sprintf(":%s", neturl.QueryEscape(opts.Pass))
|
||||
// Grab password from .pgpass file if it's available
|
||||
if opts.Pass == "" && opts.Passfile != "" {
|
||||
opts.Pass = lookupPassword(opts, nil)
|
||||
}
|
||||
|
||||
url += fmt.Sprintf("@%s:%d", opts.Host, opts.Port)
|
||||
|
||||
if opts.DbName != "" {
|
||||
url += fmt.Sprintf("/%s", opts.DbName)
|
||||
// Configure default connect timeout
|
||||
if opts.OpenTimeout > 0 {
|
||||
query.Add("connect_timeout", strconv.Itoa(opts.OpenTimeout))
|
||||
}
|
||||
|
||||
if opts.Ssl != "" {
|
||||
url += fmt.Sprintf("?sslmode=%s", opts.Ssl)
|
||||
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, nil
|
||||
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) //nolint
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package connection
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os/user"
|
||||
"testing"
|
||||
|
||||
@@ -9,93 +10,79 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_Invalid_Url(t *testing.T) {
|
||||
opts := command.Options{}
|
||||
examples := []string{
|
||||
"postgre://foobar",
|
||||
"foobar",
|
||||
}
|
||||
|
||||
for _, val := range examples {
|
||||
opts.Url = val
|
||||
str, err := BuildString(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 Test_Valid_Url(t *testing.T) {
|
||||
func TestBuildStringFromOptions(t *testing.T) {
|
||||
t.Run("valid url", func(t *testing.T) {
|
||||
url := "postgres://myhost/database"
|
||||
str, err := BuildString(command.Options{Url: url})
|
||||
str, err := BuildStringFromOptions(command.Options{URL: url})
|
||||
|
||||
assert.Equal(t, nil, err)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, url, str)
|
||||
}
|
||||
|
||||
func Test_Url_And_Ssl_Flag(t *testing.T) {
|
||||
str, err := BuildString(command.Options{
|
||||
Url: "postgres://myhost/database",
|
||||
Ssl: "disable",
|
||||
})
|
||||
|
||||
assert.Equal(t, nil, err)
|
||||
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)
|
||||
}
|
||||
|
||||
func Test_Localhost_Url_And_No_Ssl_Flag(t *testing.T) {
|
||||
str, err := BuildString(command.Options{
|
||||
Url: "postgres://localhost/database",
|
||||
})
|
||||
|
||||
assert.Equal(t, nil, err)
|
||||
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 = BuildString(command.Options{
|
||||
Url: "postgres://127.0.0.1/database",
|
||||
str, err = BuildStringFromOptions(command.Options{
|
||||
URL: "postgres://127.0.0.1/database",
|
||||
})
|
||||
|
||||
assert.Equal(t, nil, err)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "postgres://127.0.0.1/database?sslmode=disable", str)
|
||||
}
|
||||
|
||||
func Test_Localhost_Url_And_Ssl_Flag(t *testing.T) {
|
||||
str, err := BuildString(command.Options{
|
||||
Url: "postgres://localhost/database",
|
||||
Ssl: "require",
|
||||
})
|
||||
|
||||
assert.Equal(t, nil, err)
|
||||
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 = BuildString(command.Options{
|
||||
Url: "postgres://127.0.0.1/database",
|
||||
Ssl: "require",
|
||||
str, err = BuildStringFromOptions(command.Options{
|
||||
URL: "postgres://127.0.0.1/database",
|
||||
SSLMode: "require",
|
||||
})
|
||||
|
||||
assert.Equal(t, nil, err)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "postgres://127.0.0.1/database?sslmode=require", str)
|
||||
}
|
||||
|
||||
func Test_Localhost_Url_And_Ssl_Arg(t *testing.T) {
|
||||
str, err := BuildString(command.Options{
|
||||
Url: "postgres://localhost/database?sslmode=require",
|
||||
})
|
||||
|
||||
assert.Equal(t, nil, err)
|
||||
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 = BuildString(command.Options{
|
||||
Url: "postgres://127.0.0.1/database?sslmode=require",
|
||||
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)
|
||||
})
|
||||
|
||||
assert.Equal(t, nil, 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)
|
||||
})
|
||||
|
||||
func Test_Flag_Args(t *testing.T) {
|
||||
str, err := BuildString(command.Options{
|
||||
t.Run("from flags", func(t *testing.T) {
|
||||
str, err := BuildStringFromOptions(command.Options{
|
||||
Host: "host",
|
||||
Port: 5432,
|
||||
User: "user",
|
||||
@@ -103,11 +90,11 @@ func Test_Flag_Args(t *testing.T) {
|
||||
DbName: "db",
|
||||
})
|
||||
|
||||
assert.Equal(t, nil, err)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "postgres://user:password@host:5432/db", str)
|
||||
}
|
||||
})
|
||||
|
||||
func Test_Localhost(t *testing.T) {
|
||||
t.Run("localhost", func(t *testing.T) {
|
||||
opts := command.Options{
|
||||
Host: "localhost",
|
||||
Port: 5432,
|
||||
@@ -116,51 +103,182 @@ func Test_Localhost(t *testing.T) {
|
||||
DbName: "db",
|
||||
}
|
||||
|
||||
str, err := BuildString(opts)
|
||||
assert.Equal(t, nil, err)
|
||||
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 = BuildString(opts)
|
||||
assert.Equal(t, nil, err)
|
||||
str, err = BuildStringFromOptions(opts)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "postgres://user:password@127.0.0.1:5432/db?sslmode=disable", str)
|
||||
}
|
||||
})
|
||||
|
||||
func Test_Localhost_And_Ssl(t *testing.T) {
|
||||
t.Run("localhost and ssl", func(t *testing.T) {
|
||||
opts := command.Options{
|
||||
Host: "localhost",
|
||||
Port: 5432,
|
||||
User: "user",
|
||||
Pass: "password",
|
||||
DbName: "db",
|
||||
Ssl: "require",
|
||||
SSLMode: "require",
|
||||
SSLKey: "keyPath",
|
||||
SSLCert: "certPath",
|
||||
SSLRootCert: "caPath",
|
||||
}
|
||||
|
||||
str, err := BuildString(opts)
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, "postgres://user:password@localhost:5432/db?sslmode=require", str)
|
||||
}
|
||||
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)
|
||||
})
|
||||
|
||||
func Test_No_User(t *testing.T) {
|
||||
t.Run("no user", func(t *testing.T) {
|
||||
opts := command.Options{Host: "host", Port: 5432, DbName: "db"}
|
||||
u, _ := user.Current()
|
||||
str, err := BuildString(opts)
|
||||
str, err := BuildStringFromOptions(opts)
|
||||
userAndPass := url.UserPassword(u.Username, "").String()
|
||||
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, fmt.Sprintf("postgres://%s@host:5432/db", u.Username), str)
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, fmt.Sprintf("postgres://%s@host:5432/db", userAndPass), str)
|
||||
})
|
||||
|
||||
func Test_Port(t *testing.T) {
|
||||
t.Run("port", func(t *testing.T) {
|
||||
opts := command.Options{Host: "host", User: "user", Port: 5000, DbName: "db"}
|
||||
str, err := BuildString(opts)
|
||||
str, err := BuildStringFromOptions(opts)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "postgres://user:@host:5000/db", str)
|
||||
})
|
||||
|
||||
assert.Equal(t, nil, 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 Test_Blank(t *testing.T) {
|
||||
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"}))
|
||||
assert.Equal(t, false, IsBlank(command.Options{URL: "url"}))
|
||||
}
|
||||
|
||||
+6
-10
@@ -7,25 +7,21 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Check if the TCP port available on localhost
|
||||
func portAvailable(port int) bool {
|
||||
// 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 {
|
||||
if strings.Index(err.Error(), "connection refused") > 0 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
return strings.Index(err.Error(), "connection refused") > 0
|
||||
}
|
||||
|
||||
conn.Close()
|
||||
return false
|
||||
}
|
||||
|
||||
// Get available TCP port on localhost by trying available ports in a range
|
||||
func AvailablePort(start int, limit int) (int, error) {
|
||||
// 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 portAvailable(i) {
|
||||
if IsPortAvailable(i) {
|
||||
return i, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,12 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_portAvailable(t *testing.T) {
|
||||
func TestIsPortAvailable(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("FIXME")
|
||||
}
|
||||
|
||||
assert.Equal(t, true, portAvailable(30000))
|
||||
assert.Equal(t, true, IsPortAvailable(30000))
|
||||
|
||||
serv, err := net.Listen("tcp", "127.0.0.1:30000")
|
||||
if err != nil {
|
||||
@@ -35,16 +35,16 @@ func Test_portAvailable(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
assert.Equal(t, false, portAvailable(30000))
|
||||
assert.Equal(t, true, portAvailable(30001))
|
||||
assert.Equal(t, false, IsPortAvailable(30000))
|
||||
assert.Equal(t, true, IsPortAvailable(30001))
|
||||
}
|
||||
|
||||
func Test_getAvailablePort(t *testing.T) {
|
||||
func TestFindAvailablePort(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("FIXME")
|
||||
}
|
||||
|
||||
port, err := AvailablePort(30000, 1)
|
||||
port, err := FindAvailablePort(30000, 1)
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, 30000, port)
|
||||
|
||||
@@ -65,11 +65,11 @@ func Test_getAvailablePort(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
port, err = AvailablePort(30000, 0)
|
||||
port, err = FindAvailablePort(30000, 0)
|
||||
assert.EqualError(t, err, "No available port")
|
||||
assert.Equal(t, -1, port)
|
||||
|
||||
port, err = AvailablePort(30000, 1)
|
||||
port, err = FindAvailablePort(30000, 1)
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, 30001, port)
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,26 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
startTime time.Time
|
||||
promHandler http.Handler
|
||||
}
|
||||
|
||||
func (h Handler) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||
uptimeGauge.Set(time.Since(h.startTime).Seconds())
|
||||
|
||||
h.promHandler.ServeHTTP(rw, req)
|
||||
}
|
||||
|
||||
func NewHandler() http.Handler {
|
||||
return Handler{
|
||||
startTime: time.Now(),
|
||||
promHandler: promhttp.Handler(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"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",
|
||||
})
|
||||
|
||||
healthyGauge = promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "pgweb_healthy",
|
||||
Help: "Server health status",
|
||||
})
|
||||
|
||||
startTimeGauge = promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "pgweb_process_start_time",
|
||||
Help: "Server start time, seconds since unix epoch",
|
||||
})
|
||||
|
||||
uptimeGauge = promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "pgweb_uptime",
|
||||
Help: "Server application uptime in seconds",
|
||||
})
|
||||
)
|
||||
|
||||
func init() {
|
||||
startTimeGauge.Set(float64(time.Now().Unix()))
|
||||
}
|
||||
|
||||
func IncrementQueriesCount() {
|
||||
queriesCounter.Inc()
|
||||
}
|
||||
|
||||
func SetSessionsCount(val int) {
|
||||
sessionsGauge.Set(float64(val))
|
||||
}
|
||||
|
||||
func SetHealthy(val bool) {
|
||||
healthy := 0.0
|
||||
if val {
|
||||
healthy = 1.0
|
||||
}
|
||||
healthyGauge.Set(float64(healthy))
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
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, NewHandler())
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,14 @@ 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 {
|
||||
|
||||
+55
-140
@@ -1,148 +1,63 @@
|
||||
package statements
|
||||
|
||||
const (
|
||||
Databases = `
|
||||
SELECT
|
||||
datname
|
||||
FROM
|
||||
pg_database
|
||||
WHERE
|
||||
NOT datistemplate
|
||||
ORDER BY
|
||||
datname ASC`
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Schemas = `
|
||||
SELECT
|
||||
schema_name
|
||||
FROM
|
||||
information_schema.schemata
|
||||
ORDER BY
|
||||
schema_name ASC`
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Info = `
|
||||
SELECT
|
||||
session_user,
|
||||
current_user,
|
||||
current_database(),
|
||||
current_schemas(false),
|
||||
inet_client_addr(),
|
||||
inet_client_port(),
|
||||
inet_server_addr(),
|
||||
inet_server_port(),
|
||||
version()`
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TableIndexes = `
|
||||
SELECT
|
||||
indexname, indexdef
|
||||
FROM
|
||||
pg_indexes
|
||||
WHERE
|
||||
schemaname = $1 AND
|
||||
tablename = $2`
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TableConstraints = `
|
||||
SELECT
|
||||
pg_get_constraintdef(c.oid, true) as condef
|
||||
FROM
|
||||
pg_constraint c
|
||||
JOIN
|
||||
pg_namespace n ON n.oid = c.connamespace
|
||||
JOIN
|
||||
pg_class cl ON cl.oid = c.conrelid
|
||||
WHERE
|
||||
n.nspname = $1 AND
|
||||
relname = $2
|
||||
ORDER BY
|
||||
contype desc`
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TableInfo = `
|
||||
SELECT
|
||||
pg_size_pretty(pg_table_size($1)) AS data_size,
|
||||
pg_size_pretty(pg_indexes_size($1)) AS index_size,
|
||||
pg_size_pretty(pg_total_relation_size($1)) AS total_size,
|
||||
(SELECT reltuples FROM pg_class WHERE oid = $1::regclass) AS rows_count`
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TableSchema = `
|
||||
SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable,
|
||||
character_maximum_length,
|
||||
character_set_catalog,
|
||||
column_default,
|
||||
pg_catalog.col_description(($1::text || '.' || $2::text)::regclass::oid, ordinal_position) as comment
|
||||
FROM
|
||||
information_schema.columns
|
||||
WHERE
|
||||
table_schema = $1 AND
|
||||
table_name = $2`
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
MaterializedView = `
|
||||
SELECT
|
||||
attname as column_name,
|
||||
atttypid::regtype AS data_type,
|
||||
(case when attnotnull IS TRUE then 'NO' else 'YES' end) as is_nullable,
|
||||
null as character_maximum_length,
|
||||
null as character_set_catalog,
|
||||
null as column_default
|
||||
FROM
|
||||
pg_attribute
|
||||
WHERE
|
||||
attrelid = $1::regclass AND
|
||||
attnum > 0 AND
|
||||
NOT attisdropped`
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Objects = `
|
||||
SELECT
|
||||
n.nspname as "schema",
|
||||
c.relname as "name",
|
||||
CASE c.relkind
|
||||
WHEN 'r' THEN 'table'
|
||||
WHEN 'v' THEN 'view'
|
||||
WHEN 'm' THEN 'materialized_view'
|
||||
WHEN 'i' THEN 'index'
|
||||
WHEN 'S' THEN 'sequence'
|
||||
WHEN 's' THEN 'special'
|
||||
WHEN 'f' THEN 'foreign_table'
|
||||
END as "type",
|
||||
pg_catalog.pg_get_userbyid(c.relowner) as "owner",
|
||||
pg_catalog.obj_description(c.oid) as "comment"
|
||||
FROM
|
||||
pg_catalog.pg_class c
|
||||
LEFT JOIN
|
||||
pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE
|
||||
c.relkind IN ('r','v','m','S','s','') AND
|
||||
n.nspname !~ '^pg_toast' AND
|
||||
n.nspname NOT IN ('information_schema', 'pg_catalog') AND
|
||||
has_schema_privilege(n.nspname, 'USAGE')
|
||||
ORDER BY 1, 2`
|
||||
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
|
||||
|
||||
//go:embed sql/settings.sql
|
||||
Settings string
|
||||
|
||||
// Activity queries for specific PG versions
|
||||
Activity = map[string]string{
|
||||
"default": "SELECT * FROM pg_stat_activity",
|
||||
"9.1": "SELECT datname, current_query, waiting, query_start, procpid as pid, datid, application_name, client_addr FROM pg_stat_activity",
|
||||
"9.2": "SELECT datname, query, state, waiting, query_start, state_change, pid, datid, application_name, client_addr FROM pg_stat_activity",
|
||||
"9.3": "SELECT datname, query, state, waiting, query_start, state_change, pid, datid, application_name, client_addr FROM pg_stat_activity",
|
||||
"9.4": "SELECT datname, query, state, waiting, query_start, state_change, pid, datid, application_name, client_addr FROM pg_stat_activity",
|
||||
"9.5": "SELECT datname, query, state, waiting, query_start, state_change, pid, datid, application_name, client_addr FROM pg_stat_activity",
|
||||
"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",
|
||||
"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
|
||||
@@ -0,0 +1,10 @@
|
||||
SELECT
|
||||
p.oid,
|
||||
p.proname,
|
||||
p.pronamespace,
|
||||
p.proowner,
|
||||
pg_get_functiondef(oid) AS functiondef
|
||||
FROM
|
||||
pg_catalog.pg_proc p
|
||||
WHERE
|
||||
oid = $1::oid
|
||||
@@ -0,0 +1,10 @@
|
||||
SELECT
|
||||
session_user,
|
||||
current_user,
|
||||
current_database(),
|
||||
current_schemas(false),
|
||||
inet_client_addr(),
|
||||
inet_client_port(),
|
||||
inet_server_addr(),
|
||||
inet_server_port(),
|
||||
version()
|
||||
@@ -0,0 +1,6 @@
|
||||
SELECT
|
||||
session_user,
|
||||
current_user,
|
||||
current_database(),
|
||||
current_schemas(false),
|
||||
version()
|
||||
@@ -0,0 +1,13 @@
|
||||
SELECT
|
||||
attname AS column_name,
|
||||
atttypid::regtype AS data_type,
|
||||
(CASE WHEN attnotnull IS TRUE THEN 'NO' ELSE 'YES' END) AS is_nullable,
|
||||
NULL AS character_maximum_length,
|
||||
NULL AS character_set_catalog,
|
||||
NULL AS column_default
|
||||
FROM
|
||||
pg_attribute
|
||||
WHERE
|
||||
attrelid = $1::regclass
|
||||
AND attnum > 0
|
||||
AND NOT attisdropped
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user