Compare commits

...
119 Commits
Author SHA1 Message Date
balki 3312c357f3 Support unix sockets and systemd sockets using anyhttp 2026-01-01 17:08:16 -05:00
Dan SosedoffandGitHub fb6091bb83 Update gin to v1.11 (#831) 2025-12-30 11:04:35 -08:00
Alexandru GologanandGitHub 16f88a1336 build with go v1.25 (#814) 2025-12-30 09:01:12 -08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e0fcd058df Bump github.com/sirupsen/logrus from 1.9.0 to 1.9.1 (#827)
Bumps [github.com/sirupsen/logrus](https://github.com/sirupsen/logrus) from 1.9.0 to 1.9.1.
- [Release notes](https://github.com/sirupsen/logrus/releases)
- [Changelog](https://github.com/sirupsen/logrus/blob/master/CHANGELOG.md)
- [Commits](https://github.com/sirupsen/logrus/compare/v1.9.0...v1.9.1)

---
updated-dependencies:
- dependency-name: github.com/sirupsen/logrus
  dependency-version: 1.9.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-27 11:43:53 -08:00
Dan Sosedoff 9e37bd27f1 Update changelog for 0.17.0 2025-11-22 07:53:51 -08:00
Dan Sosedoff 6b0b0244d1 Version bump: 0.17.0 2025-11-22 07:21:34 -08:00
Dan Sosedoff c6ece21133 Remove obsolete reference from docker compose file 2025-11-22 07:19:34 -08:00
Dan Sosedoff 4919407228 Fix ENV reference in Dockerfile 2025-11-22 07:18:35 -08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1c3c82de18 Bump golang.org/x/crypto from 0.44.0 to 0.45.0 (#823)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.44.0 to 0.45.0.
- [Commits](https://github.com/golang/crypto/compare/v0.44.0...v0.45.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.45.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-22 07:07:41 -08:00
Dan SosedoffandGitHub 06d73805a8 Minor session manager code cleanup and refactor (#822)
* Return a copy of all sessions for session manager Sessions method()
* Use standard mutex Lock method when iterating over stale sessions
* Use defer for consistency
2025-11-16 13:12:28 -08:00
Dan SosedoffandGitHub 74e8482f96 Bump go crypto package to 0.44.0 (#820) 2025-11-15 10:05:15 -08:00
Dan SosedoffandGitHub 79d8afc715 Update dockerfile to use golang 1.24 image (#821) 2025-11-11 22:30:18 -08:00
Dan SosedoffandGitHub 9a5df8487a Use go1.24 in CI and scripts (#818) 2025-11-11 22:18:57 -08:00
Dan SosedoffandGitHub 3fb87562ed Bump golangci-lint version in CI to v1.64.8 (#819) 2025-11-11 12:32:39 -08:00
Dan SosedoffandGitHub 6a52fea44a Add extra test for verifiying ssh connection options (#817)
* Add extra test for verifiying ssh connection options
* Fix failing manager test
* Fix another test
2025-11-11 11:55:42 -08:00
Dan SosedoffandGitHub e3085a44ba Add PostgreSQL 18 support to Github Actions (#816)
* Run against PostgreSQL 18
* Handle constraints for pg18
* Bump golangci-linter to 1.62.2 (#772)
* Bump golangci-linter to 1.62.2
* Address linting warning via change or ignore
2025-11-11 11:30:42 -08:00
Dan SosedoffandGitHub 4ce11c039e Bump golangci-linter to 1.62.2 (#772)
* Bump golangci-linter to 1.62.2
* Address linting warning via change or ignore
2025-11-11 11:26:49 -08:00
Dan SosedoffandGitHub 70f62feec8 Connect backend refactor (#801)
* Move connect backend code to its own package
* Move errors into the connect package
* Add NewBackend func
2025-11-11 10:19:05 -08:00
Dan Sosedoff 266a516076 Update connect backend code to work on latest ruby version 2025-06-15 12:32:30 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2e8800150e Bump rack from 2.2.11 to 2.2.14 in /config/examples/connect_backend_ruby (#792)
Bumps [rack](https://github.com/rack/rack) from 2.2.11 to 2.2.14.
- [Release notes](https://github.com/rack/rack/releases)
- [Changelog](https://github.com/rack/rack/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rack/rack/compare/v2.2.11...v2.2.14)

---
updated-dependencies:
- dependency-name: rack
  dependency-version: 2.2.14
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-15 12:26:44 -07:00
Dan SosedoffandGitHub 8413681db0 Fix typo in the list of env vars (#786) 2025-03-30 08:28:18 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ed8a581ff2 Bump rack in /config/examples/connect_backend_ruby (#777)
Bumps [rack](https://github.com/rack/rack) from 2.2.6.2 to 2.2.11.
- [Release notes](https://github.com/rack/rack/releases)
- [Changelog](https://github.com/rack/rack/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rack/rack/compare/v2.2.6.2...v2.2.11)

---
updated-dependencies:
- dependency-name: rack
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-16 10:04:46 -08:00
Dan SosedoffandGitHub 302aa1d85b Shorten git revision printed in the --version output (#770) 2024-12-20 21:30:37 -08:00
Dan SosedoffandGitHub f5f78eb32e Add server settings view (#768) 2024-12-15 14:46:01 -08:00
Oleg SamsonovandGitHub e6df3d19fa add exec time of empty queries (#763)
* add exec time of empty queries
* add { }
* fix space
* use proposed format
2024-11-19 21:55:44 -08:00
Kian-Meng AngandGitHub 08b1ea71e7 Fix typos (#764)
Found via `codespell -S static,data -L selct`
2024-11-17 22:08:29 -08:00
Dan Sosedoff 7fe302ed7c Update changelog formatting 2024-11-02 09:28:51 -07:00
Dan Sosedoff bcd170ab74 Update changelog 2024-11-02 09:26:17 -07:00
Dan Sosedoff e63945ee71 Version bump: 0.16.2 2024-11-02 09:19:33 -07:00
Dan SosedoffandGitHub 487e2820f4 Try running checks against pg 17 (#758)
* Try running checks against pg 17

* Install postgresql-client-17
2024-10-16 20:28:27 -07:00
Dan SosedoffandGitHub a716093236 Update references from master branch to main branch (#750) 2024-09-08 08:17:18 -07:00
Dan Sosedoff f8bfe2de56 Update changelog 2024-09-07 09:49:04 -07:00
Dan Sosedoff 136b2a6942 Version bump: 0.16.1 2024-09-07 09:48:07 -07:00
Dan SosedoffandGitHub c2c69e032c Remove linux/arm/v5 from docker release action (#742) 2024-06-06 19:15:26 -07:00
Dan Sosedoff 3ff70b916f Update changelog 2024-06-04 21:28:17 -07:00
Dan Sosedoff 22eb352180 Version bump: 0.16.0 2024-06-04 21:15:52 -07:00
Dan SosedoffandGitHub 3a32f531a0 Allow database stats downloads (#738)
* Add button to download database stats
* Return xml as well
2024-05-25 10:59:23 -07:00
Dan SosedoffandGitHub 63f1150056 SSH tunnel cleanup and parse fixup (#731) 2024-05-22 20:09:29 -07:00
Dan SosedoffandGitHub 40f582d1ea Add analyze table action (#737) 2024-05-14 20:40:39 -07:00
Dan SosedoffandGitHub cf86131808 Bump postgres version used in docker compose to 15 (#729) 2024-03-27 23:02:46 -07:00
Matt BurdanandGitHub f3d6d9d73e fix: Propagate CGO_ENABLED environment variable to docker build (#724)
* fix: Add ability to statically compile binaries

* propagate env var from github action

* fix comment
2024-03-26 22:44:58 -07:00
Dan SosedoffandGitHub 1b03d78800 Drop linux/arm/v5 from docker build (#728) 2024-03-25 22:44:41 -07:00
Dan SosedoffandGitHub 6489f6a800 Build on 1.22 (#726) 2024-03-22 21:03:07 -07:00
Dan SosedoffandGitHub 36436f3aa7 Switch to go 1.21 (#702)
* Switch to go 1.21

* Update golangci-lint version
2024-03-22 20:36:41 -07:00
Dan SosedoffandGitHub db05989d07 Remove herokuisms (#719) 2024-03-15 22:01:32 -07:00
Dan Sosedoff 3b2bec68ef Update changelog 2024-03-14 22:28:03 -07:00
Dan Sosedoff 9717d3a132 Version bump: 0.15.0 2024-03-14 22:19:59 -07:00
Alexandru GologanandGitHub f4e7643e22 Add support for a bookmarks-only mode (#716)
* Add support for bookmarks-only mode

* Add error for missing bookmarks in bookmarks-only mode

* Error when settings url or connect backend together with bookmarks-only

* Add tests for parsing options
2024-03-14 21:36:53 -07:00
dmahmalatandGitHub 605c483d5b Continue on parseJSON error (#708) 2024-03-11 22:03:37 -07:00
Ashish KulkarniandGitHub 8a8b9f07fb fix missing indexes by quoting schema/table name to ::regclass (#711)
The same approach is used in pkg/statements/sql/table_schema.sql
and a sample reproduction for the bug is:

  CREATE SCHEMA test;
  CREATE TABLE test.data (id INTEGER PRIMARY KEY, name TEXT);
  CREATE UNIQUE INDEX "test.data_uniq" ON test.data (name);

Only the primary key is shown, and not the index created above.
2024-03-11 21:50:48 -07:00
Dan Sosedoff 7ee3c61e38 Version bump: 0.14.3 2024-01-28 20:14:20 -06:00
Dan SosedoffandGitHub 408e23adb3 Allow setting readonly mode in bookmarks (#707) 2024-01-12 21:17:14 -06:00
Dan Sosedoff e560f07de6 License years 2024-01-04 23:34:40 -06:00
Dan SosedoffandGitHub fe5039d17a Allow retrying a connection on startup (#695)
* Allow retrying a connection on startup
* Remove unused vars
* Add an extra comment
* Restructure retry logic a bit
* Update retry logic
* Fix comment
* Update comment
* Change type for RetryCount and RetryDelay to uint
* Extra test cases
* Tweak
2023-11-04 11:12:48 -05:00
Dan SosedoffandGitHub f810c0227b Add UPDATE to list of restricted keywords in read-only mode (#697) 2023-11-02 21:17:46 -05:00
Dan Sosedoff 10c90bcd01 Reformat changelog 2023-10-29 21:29:55 -05:00
Dan Sosedoff f3a6ce4636 Update changelog 2023-10-29 21:18:05 -05:00
Dan Sosedoff 87030c8a17 Version bump: 0.14.2 2023-10-29 21:17:00 -05:00
Dan Sosedoff 614700e69a Bump max ci run to 40 mins 2023-09-26 23:22:59 -05:00
Dan SosedoffandGitHub 5371b4af56 Test with pg16 (#691)
* Test with pg16

* Install pg 16 client
2023-09-26 22:40:57 -05:00
Dan SosedoffandGitHub 22daaad5a2 Fix unclosed database sessions and tunnels (#688) 2023-09-10 11:46:31 -05:00
yanjingtuandGitHub 14d8d80b86 fix when prefix has '/' and prefix is not end with '/' (#684) 2023-08-29 21:09:10 -07:00
Dan SosedoffandGitHub e0c2099e91 Use pg_table_size for table stats query (#685) 2023-07-18 20:15:09 -05:00
Dan Sosedoff a4185415e2 Update changelog 2023-06-17 12:19:21 -05:00
Dan Sosedoff 189ca505bc Version bump: 0.14.1 2023-06-17 12:16:37 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
0309d88298 Bump github.com/gin-gonic/gin from 1.7.7 to 1.9.1 (#680)
Bumps [github.com/gin-gonic/gin](https://github.com/gin-gonic/gin) from 1.7.7 to 1.9.1.
- [Release notes](https://github.com/gin-gonic/gin/releases)
- [Changelog](https://github.com/gin-gonic/gin/blob/master/CHANGELOG.md)
- [Commits](https://github.com/gin-gonic/gin/compare/v1.7.7...v1.9.1)

---
updated-dependencies:
- dependency-name: github.com/gin-gonic/gin
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-06-17 12:03:46 -05:00
Dan Sosedoff 60532e852b Add env and envrc into gitignore 2023-06-17 12:01:21 -05:00
Dan SosedoffandGitHub d4cfb059ce Add process start time metric (#675) 2023-05-08 20:46:00 -05:00
Dan SosedoffandGitHub 52f7988ebd Configure pgweb user for docker container (#674)
* Configure pgweb user for docker container
* Set UID to 1000
2023-04-29 14:07:29 -05:00
Dan Sosedoff b2067fbc8d Add clarification comment for binary codec var 2023-04-25 20:02:41 -05:00
Dan Sosedoff f037a4b0d2 Change demo env idle timeout to 30 mins 2023-04-04 15:43:30 -05:00
Dan SosedoffandGitHub 4d32e3b2c5 Switch to go 1.20 (#668)
* Switch to go 1.20
* Fix golang version definition
* Upgrade golangci-lint to v1.51.2
2023-03-31 19:48:14 -05:00
Dan SosedoffandGitHub f94bc9a4dd Fix connection window layout on smaller viewports (#669) 2023-03-31 19:38:38 -05:00
Francesco FrassinelliandGitHub e905e5de53 Better Dockerfile (#645)
* Add support for docker compose build
* Avoid copying more file than needed
* Reduce Docker image size
2023-03-31 18:55:59 -05:00
Dan SosedoffandGitHub 08bbb1537e Implement process uptime metric (#666)
* Implement process uptime metric
* Add clarification comment
* Add another clarification comment
2023-03-30 12:51:49 -05:00
Dan SosedoffandGitHub 941c0acea3 Fix typo in the healthy metric (#657) 2023-02-28 11:57:36 -06:00
TobiasandGitHub bed7ab9564 Use entrypoint instead of cmd in order to facilitate argument passing (#654)
See https://docs.docker.com/engine/reference/builder/#entrypoint and https://docs.docker.com/engine/reference/builder/#cmd.
This allows specifying additional arguments directly after 'docker run ...', e.g. docker run --rm sosedoff/pgweb --listen=8082
2023-02-27 13:14:05 -06:00
Dan Sosedoff d5e72f92b5 Update changelog 2023-02-21 11:49:04 -06:00
Dan Sosedoff 6b3c177909 Version bump: 0.14.0 2023-02-21 11:39:49 -06:00
Dan SosedoffandGitHub c486885159 Fetch local queries on db connect (#650) 2023-02-17 12:26:37 -06:00
Dan Sosedoff fc0be1dcd1 README update 2023-02-15 22:04:37 -06:00
Dan Sosedoff f12cf3725a Move gitconfig step into dockerfile build section 2023-02-15 16:59:39 -06:00
Dan Sosedoff 23fb84db76 Add git config for fly deploy 2023-02-15 16:48:17 -06:00
Ashish KulkarniandGitHub 505ee42867 ensure that objects are sorted by schema and name (#648)
This is a regression from #608
2023-02-15 15:39:17 -06:00
Dan SosedoffandGitHub 41bf189e6b Local queries (#641)
* Read local queries from pgweb home directory
* Refactor local query functionality
* Allow picking local query in the query tab
* WIP
* Disable local query dropdown during execution
* Only allow local queries running in a single session mode
* Add middleware to enforce local query endpoint availability
* Fix query check
* Add query store tests
* Make query store errors portable
* Skip building specific tests on windows
2023-02-02 16:13:14 -06:00
Francesco FrassinelliandGitHub 1c3ab1fd1c Replace DATABASE_URL with PGWEB_DATABASE_URL (#642) 2023-02-02 12:48:42 -06:00
Dan SosedoffandGitHub 5b2d4e4454 Add context menu to display database tables stats (#639)
* Add context menu to display database tables stats

* Move table stats implementation into client
2023-01-23 14:10:26 -06:00
Jens BroekensandGitHub 06212b4c34 Add bookmark options to load username/password from env vars (#638) 2023-01-20 16:59:39 -06:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
79bd9c2f4e Bump rack from 2.2.4 to 2.2.6.2 in /config/examples/connect_backend_ruby (#637)
Bumps [rack](https://github.com/rack/rack) from 2.2.4 to 2.2.6.2.
- [Release notes](https://github.com/rack/rack/releases)
- [Changelog](https://github.com/rack/rack/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rack/rack/compare/2.2.4...v2.2.6.2)

---
updated-dependencies:
- dependency-name: rack
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-18 20:09:55 -06:00
Dan SosedoffandGitHub d7ecb5494d Show error message when API calls fail (#636) 2023-01-18 13:29:04 -06:00
Dan Sosedoff 06be755d56 Update license years 2023-01-17 17:28:45 -06:00
Dan SosedoffandGitHub b2b0094b5e Handle support/permissions errors in info call (#635)
* Handle support/permissions errors in info call
* Fix linting
2023-01-17 16:05:15 -06:00
Dan SosedoffandGitHub 96bd15b3e2 Display cell content via context menu (#634) 2023-01-12 11:55:34 -06:00
Dan SosedoffandGitHub f3353d9007 History page query loading fixup (#632) 2022-12-28 21:56:29 -06:00
Dan Sosedoff a864cbcee8 Update changelog 2022-12-27 19:20:32 -06:00
Dan Sosedoff da671e67cc Version bump: 0.13.1 2022-12-27 19:14:59 -06:00
Dan SosedoffandGitHub 5afecfe52c Fix bookmarks ID check (#631) 2022-12-27 19:03:53 -06:00
Dan Sosedoff fed842a44d Configure fly metrics 2022-12-25 15:06:26 -06:00
Dan Sosedoff 16a897e65d Update changelog 2022-12-25 14:20:38 -06:00
Dan Sosedoff e4530caab6 Version bump: 0.13.0 2022-12-25 14:14:16 -06:00
Dan Sosedoff 6bd8d0f9a7 Enable metrics endpoint on fly 2022-12-25 13:45:10 -06:00
Dan SosedoffandGitHub 239037b4c6 Add query execution stats to api endpoint (#629) 2022-12-25 13:25:18 -06:00
Dan SosedoffandGitHub 12a4bf70c0 Add duration_ms to log entries (#628) 2022-12-25 13:24:22 -06:00
Dan SosedoffandGitHub 1897bef08d Results struct cleanup (#627)
* Add results format test
* Init results slice before formatting data
* Add extra test for nil check in CSV function
2022-12-21 15:02:40 -06:00
Dan Sosedoff aeb9dc24ad Fix mutex unlock call 2022-12-21 14:29:44 -06:00
Dan SosedoffandGitHub bcba666507 Add default connect_timeout option to connection string (#626)
* Add default connect_timeout option to connection string
* Add an extra test
2022-12-20 16:58:54 -06:00
Dan SosedoffandGitHub b31e7f1ea7 Setup basic prom metrics endpoint (#624)
* Setup basic prom metrics endpoint
* Use default prom handler to expose go runtime metrics
2022-12-20 10:13:42 -06:00
Dan Sosedoff 837e25be74 Go mod tidy 2022-12-19 17:51:35 -06:00
Dan SosedoffandGitHub 4e3e255575 Configure timeout and retries when testing connection status (#623) 2022-12-19 16:13:43 -06:00
Dan Sosedoff 362ecb0bc7 Tweak fly config 2022-12-19 13:39:36 -06:00
Dan SosedoffandGitHub 0cd61093b9 Display empty schemas on the sidebar (#621)
* Display empty schemas on the sidebar
* Add schemas fetching test
* Skip pg_temp schemas
* Exclude pg_temp tables from other queries
2022-12-19 13:26:13 -06:00
Dan SosedoffandGitHub 69233cd769 Establish connections using bookmark ID only (#619)
* Establish connections using bookmark ID only
* Refactor specs
* Extra tests
* Fix homedir assertion for bookmarks path
* Fix newline in the warning message
* Check for bookmark file existence before reading
* Connect code restructure
2022-12-19 12:33:13 -06:00
Dan Sosedoff 0b9e7cdb4e Update checks workflow triggers 2022-12-16 15:03:06 -06:00
Dan SosedoffandGitHub afe431c94d Request logging additions (request id, forwarded user) (#618)
* Add request-id logging
* Missing test file
* Add option to log forwarded user information if available via X-Forwarded-Header
* Format
2022-12-16 12:38:57 -06:00
Dan SosedoffandGitHub e0a748812d Add support for .pgpass file (#617)
* Add support for .pgpass file
* Support password lookup in /api/connect endpoint
* Restrore removed code for BuildStringFromOptions
* Restructure connection string test and add extra case for pgpass
* Add test for FormatURL method
2022-12-14 13:37:49 -06:00
Dan Sosedoff 40474d3990 Remove CGO_ENABLED=0 from checks workflow 2022-12-13 15:28:14 -06:00
Dan Sosedoff 41d970e4a7 Remove appveyor build badge 2022-12-13 12:42:24 -06:00
Dan Sosedoff 044367b979 Remove deploy to heroku button 2022-12-13 12:40:04 -06:00
Dan Sosedoff 515f3123dc Update changelog 2022-12-13 12:20:32 -06:00
79 changed files with 3422 additions and 1057 deletions
+18 -12
View File
@@ -1,12 +1,21 @@
name: checks
on:
- push
- pull_request
push:
branches:
- main
paths-ignore:
- '**.md'
pull_request:
types:
- opened
- synchronize
paths-ignore:
- '**.md'
env:
GO_VERSION: 1.19
CGO_ENABLED: 0
GO_VERSION: "1.25"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -16,10 +25,10 @@ jobs:
tests:
name: tests
runs-on: ubuntu-latest
timeout-minutes: 30
timeout-minutes: 40
strategy:
matrix:
pg_version: [9.6, 10, 11, 12, 13, 14, 15]
pg_version: [9.6, 10, 11, 12, 13, 14, 15, 16, 17, 18]
services:
postgres:
@@ -41,7 +50,7 @@ jobs:
sudo rm -f /etc/apt/sources.list.d/pgdg.list
curl --silent https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add
echo "deb http://apt.postgresql.org/pub/repos/apt/ `lsb_release -cs`-pgdg main" | sudo tee /etc/apt/sources.list.d/pgdg.list
sudo apt-get update && sudo apt-get install -y postgresql-client-15
sudo apt-get update && sudo apt-get install -y postgresql-client-18
- uses: actions/checkout@v3
with:
@@ -53,7 +62,6 @@ jobs:
- run: make test
env:
MallocNanoZone: 0 # https://github.com/golang/go/issues/49138
CGO_ENABLED: 1
PGHOST: localhost
PGUSER: postgres
PGPASSWORD: postgres
@@ -72,8 +80,6 @@ jobs:
go-version: ${{ env.GO_VERSION }}
- run: go mod download
- run: make test
env:
CGO_ENABLED: 1
lint:
runs-on: ubuntu-latest
@@ -89,9 +95,9 @@ jobs:
fetch-depth: 0
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
uses: golangci/golangci-lint-action@v8
with:
version: v1.50.1
version: v2.7.2
fmt:
name: fmt
+1 -1
View File
@@ -3,7 +3,7 @@ name: demo deploy
on:
push:
branches:
- master
- main
env:
FLY_API_TOKEN: ${{ secrets.FLY_TOKEN }}
+5 -3
View File
@@ -3,10 +3,10 @@ name: docker
on:
push:
branches:
- master
- main
env:
GO_VERSION: 1.19
GO_VERSION: "1.25"
CGO_ENABLED: 0
IMAGE_REPOSITORY: sosedoff/pgweb
@@ -32,4 +32,6 @@ jobs:
context: .
push: false
tags: pgweb:latest
platforms: linux/amd64,linux/arm64,linux/arm/v5,linux/arm/v7
platforms: linux/amd64,linux/arm64,linux/arm/v7
build-args: |
"CGO_ENABLED=${{ env.CGO_ENABLED }}"
+2 -2
View File
@@ -6,7 +6,7 @@ on:
- "v*"
env:
GO_VERSION: 1.19
GO_VERSION: "1.25"
CGO_ENABLED: 0
DOCKER_REPOSITORY: sosedoff/pgweb
GHCR_REPOSITORY: sosedoff/pgweb
@@ -57,4 +57,4 @@ jobs:
${{ env.DOCKER_REPOSITORY }}:latest
ghcr.io/${{ env.GHCR_REPOSITORY }}:${{ steps.refs.outputs.SOURCE_TAG }}
ghcr.io/${{ env.GHCR_REPOSITORY }}:latest
platforms: linux/amd64,linux/arm64,linux/arm/v5,linux/arm/v7
platforms: linux/amd64,linux/arm64,linux/arm/v7
+2
View File
@@ -1,5 +1,7 @@
.DS_Store
.idea
.env
.envrc
pgweb
bin
tmp/
+7
View File
@@ -0,0 +1,7 @@
version: "2"
linters:
disable:
- errcheck
settings:
staticcheck:
checks: ["all", "-ST1000", "-ST1003", "-ST1016", "-ST1020", "-ST1021", "-ST1022", "-ST1005", "-QF1004"]
+121 -4
View File
@@ -1,9 +1,126 @@
# 0.11.12 - 2022-07-05
## 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
## 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
@@ -268,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
@@ -282,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
View File
@@ -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
+30 -18
View File
@@ -1,36 +1,48 @@
# ------------------------------------------------------------------------------
# Builder Stage
# ------------------------------------------------------------------------------
FROM golang:1.19-bullseye AS build
FROM golang:1.25-trixie AS build
# Set default build argument for CGO_ENABLED
ARG CGO_ENABLED=0
ENV CGO_ENABLED=${CGO_ENABLED}
WORKDIR /build
ADD . /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:bullseye-slim
FROM debian:trixie-slim
RUN \
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 -y ca-certificates openssl netcat curl gnupg lsb-release && \
update-ca-certificates
RUN \
curl --silent https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add && \
echo "deb http://apt.postgresql.org/pub/repos/apt/ `lsb_release -cs`-pgdg main" | tee /etc/apt/sources.list.d/pgdg.list && \
apt-get update && apt-get install -y postgresql-client
RUN \
apt-get clean autoclean && \
apt-get autoremove --yes && \
rm -rf /var/lib/{apt,dpkg,cache,log}/
apt-get install -qq --no-install-recommends ca-certificates openssl netcat-openbsd curl postgresql-client
COPY --from=build /build/pgweb /usr/bin/pgweb
EXPOSE 8081
RUN useradd --uid 1000 --no-create-home --shell /bin/false pgweb
USER pgweb
CMD ["/usr/bin/pgweb", "--bind=0.0.0.0", "--listen=8081"]
EXPOSE 8081
ENTRYPOINT ["/usr/bin/pgweb", "--bind=0.0.0.0", "--listen=8081"]
+1 -1
View File
@@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2014-2022 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 -1
View File
@@ -1,5 +1,5 @@
PKG = github.com/sosedoff/pgweb
GIT_COMMIT ?= $(shell git rev-parse HEAD)
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'})
+19 -40
View File
@@ -1,60 +1,43 @@
# pgweb
Web-based PostgreSQL database browser written in Go.
Simple web-based and cross platform PostgreSQL database explorer.
[![Release](https://img.shields.io/github/release/sosedoff/pgweb.svg?label=Release)](https://github.com/sosedoff/pgweb/releases)
[![Linux Build](https://github.com/sosedoff/pgweb/actions/workflows/checks.yml/badge.svg)](https://github.com/sosedoff/pgweb/actions?query=branch%3Amaster)
[![Windows Build](https://img.shields.io/appveyor/ci/sosedoff/pgweb/master.svg?label=Windows)](https://ci.appveyor.com/project/sosedoff/pgweb)
[![Linux Build](https://github.com/sosedoff/pgweb/actions/workflows/checks.yml/badge.svg)](https://github.com/sosedoff/pgweb/actions?query=branch%3Amain)
[![Go Report Card](https://goreportcard.com/badge/github.com/sosedoff/pgweb)](https://goreportcard.com/report/github.com/sosedoff/pgweb)
[![GoDoc](https://godoc.org/github.com/sosedoff/pgweb?status.svg)](https://godoc.org/github.com/sosedoff/pgweb)
[![Docker Pulls](https://img.shields.io/docker/pulls/sosedoff/pgweb.svg)](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
## Pgweb Pro
Pgweb Pro is the next major version of Pgweb and includes features like:
- Table structure editing
- Data editing (update row content)
- Charting
- History persistence
- Multiple tabs
Please get in touch via: https://goo.gl/forms/euQOGWg5uPdPH70b2
Visit [WIKI](https://github.com/sosedoff/pgweb/wiki) for more details.
## Demo
Visit https://pgweb-demo.fly.dev/ 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.
- [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
@@ -89,13 +72,9 @@ pgweb --sessions
Or set environment variable:
```
SESSIONS=1 pgweb
PGWEB_SESSIONS=1 pgweb
```
## Deploy on Heroku
[![Heroku Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/sosedoff/pgweb)
## Testing
Before running tests, make sure you have PostgreSQL server running on `localhost:5432`
-27
View File
@@ -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"
}
}
}
@@ -3,3 +3,4 @@ source "https://rubygems.org"
gem "sinatra"
gem "json"
gem "puma"
gem "rackup"
@@ -1,29 +1,43 @@
GEM
remote: https://rubygems.org/
specs:
json (2.6.2)
mustermann (3.0.0)
base64 (0.3.0)
json (2.12.2)
logger (1.7.0)
mustermann (3.0.3)
ruby2_keywords (~> 0.0.1)
nio4r (2.5.8)
puma (6.0.0)
nio4r (2.7.4)
puma (6.6.0)
nio4r (~> 2.0)
rack (2.2.4)
rack-protection (3.0.4)
rack
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 (3.0.4)
sinatra (4.1.1)
logger (>= 1.6.0)
mustermann (~> 3.0)
rack (~> 2.2, >= 2.2.4)
rack-protection (= 3.0.4)
rack (>= 3.0.0, < 4)
rack-protection (= 4.1.1)
rack-session (>= 2.0.0, < 3)
tilt (~> 2.0)
tilt (2.0.11)
tilt (2.6.0)
PLATFORMS
arm64-darwin-21
arm64-darwin-24
x86_64-linux
DEPENDENCIES
json
puma
rackup
sinatra
BUNDLED WITH
+2 -1
View File
@@ -1,9 +1,10 @@
require "bundler/setup"
require "sinatra"
# Authentication token
$token = "test"
# List of all availble resources
# List of all available resources
$resources = {
"id1" => "postgres://localhost:5432/db1?sslmode=disable",
"id2" => "postgres://localhost:5432/db2?sslmode=disable",
+12
View File
@@ -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
View File
@@ -0,0 +1,2 @@
-- pgweb: host="localhost"
select 'foo'
+5
View File
@@ -0,0 +1,5 @@
-- pgweb: host="localhost"
-- some comment
-- pgweb: user="foo"
select 'foo'
+2
View File
@@ -0,0 +1,2 @@
-- pgweb: host="localhost" mode="foo"
select 'foo'
+1
View File
@@ -0,0 +1 @@
select 'foo'
+2
View File
@@ -0,0 +1,2 @@
localhost:5432:dbname:username:password
127.0.0.1:5432:*:*:password2
+15 -2
View File
@@ -1,6 +1,4 @@
---
version: "3.9"
x-base: &base
environment: &env
POSTGRES_DB: pgweb
@@ -11,6 +9,21 @@ x-base: &base
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
+3 -4
View File
@@ -1,10 +1,8 @@
---
version: "3.9"
services:
postgres:
container_name: pgweb-postgres
image: postgres:14
image: postgres:15
ports:
- 5433:5432
volumes:
@@ -22,8 +20,9 @@ services:
pgweb:
container_name: pgweb
image: sosedoff/pgweb:latest
build: .
environment:
DATABASE_URL: postgres://pgweb:pgweb@pgweb-postgres:5432/pgweb?sslmode=disable
PGWEB_DATABASE_URL: postgres://pgweb:pgweb@pgweb-postgres:5432/pgweb?sslmode=disable
ports:
- 8081:8081
networks:
+6 -10
View File
@@ -1,18 +1,9 @@
# fly.toml file generated for pgweb-demo on 2022-11-14T11:19:39-06:00
app = "pgweb-demo"
kill_signal = "SIGINT"
kill_timeout = 5
[env]
SESSIONS = "1"
[experimental]
allowed_public_ports = []
auto_rollback = true
[processes]
web = "pgweb --sessions --bind=0.0.0.0"
web = "pgweb --sessions --bind=0.0.0.0 --metrics --idle-timeout=30"
[[services]]
http_checks = []
@@ -20,6 +11,7 @@ kill_timeout = 5
processes = ["web"]
protocol = "tcp"
script_checks = []
[services.concurrency]
hard_limit = 25
soft_limit = 20
@@ -39,3 +31,7 @@ kill_timeout = 5
interval = "15s"
restart_limit = 0
timeout = "2s"
[metrics]
port = 8081
path = "/metrics"
+41 -19
View File
@@ -1,42 +1,64 @@
module github.com/sosedoff/pgweb
go 1.19
go 1.25
// +heroku goVersion go1.19
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.7.7
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/sirupsen/logrus v1.9.0
github.com/stretchr/testify v1.7.1
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
golang.org/x/crypto v0.0.0-20220511200225-c6db032c6c88
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/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.0 // indirect
github.com/go-playground/universal-translator v0.18.0 // indirect
github.com/go-playground/validator/v10 v10.9.0 // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/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/leodido/go-urn v1.2.1 // indirect
github.com/mattn/go-isatty v0.0.14 // 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/ugorji/go/codec v1.2.6 // indirect
golang.org/x/sys v0.2.0 // indirect
golang.org/x/text v0.3.7 // indirect
google.golang.org/protobuf v1.27.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // 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
)
+93 -82
View File
@@ -2,60 +2,68 @@ 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/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.7.7 h1:3DoBmSbJbZAWqXJC3SLjAPfutPJJRN1U5pALB7EeTTs=
github.com/gin-gonic/gin v1.7.7/go.mod h1:axIBovoeJpVj8S3BwE0uPMTeReE4+AfFtqpqaZ1qq1U=
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU=
github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho=
github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
github.com/go-playground/validator/v10 v10.9.0 h1:NgTtmN58D0m8+UuxtYmGztBJB7VnPgjj221I1QHci2A=
github.com/go-playground/validator/v10 v10.9.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos=
github.com/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/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
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.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/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.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
github.com/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.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-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=
@@ -63,78 +71,81 @@ github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrk
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/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/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/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.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY=
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/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
github.com/ugorji/go v1.2.6/go.mod h1:anCg0y61KIhDlPZmnH+so+RQbysYVyDko0IMgJv0Nn0=
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
github.com/ugorji/go/codec v1.2.6 h1:7kbGefxLoDBuYXOms4yD7223OpNMMPNPZxXk5TvFcyQ=
github.com/ugorji/go/codec v1.2.6/go.mod h1:V6TCNZ4PHqoHGFZuSG1W8nrCzzdgA2DozYxWFFpvxTw=
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.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220511200225-c6db032c6c88 h1:Tgea0cVUD0ivh5ADBX4WwuI12DUd2to3nCYe2eayMIw=
golang.org/x/crypto v0.0.0-20220511200225-c6db032c6c88/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/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.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
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-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200219091948-cb0a6d8edb6c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/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.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
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-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+198 -28
View File
@@ -15,7 +15,10 @@ import (
"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"
)
@@ -26,6 +29,9 @@ var (
// 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
@@ -87,18 +93,18 @@ func GetSessions(c *gin.Context) {
// ConnectWithBackend creates a new connection based on backend resource
func ConnectWithBackend(c *gin.Context) {
// Setup a new backend client
backend := Backend{
Endpoint: command.Opts.ConnectBackend,
Token: command.Opts.ConnectToken,
PassHeaders: strings.Split(command.Opts.ConnectHeaders, ","),
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)
cred, err := backend.FetchCredential(ctx, c.Param("resource"), c.Request.Header)
if err != nil {
badRequest(c, err)
return
@@ -142,27 +148,18 @@ func Connect(c *gin.Context) {
return
}
var sshInfo *shared.SSHInfo
url := c.Request.FormValue("url")
var (
cl *client.Client
err error
)
if url == "" {
badRequest(c, errURLRequired)
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 {
badRequest(c, err)
return
}
if c.Request.FormValue("ssh") != "" {
sshInfo = parseSshInfo(c)
}
cl, err := client.NewFromUrl(url, sshInfo)
if err != nil {
badRequest(c, err)
return
@@ -187,6 +184,39 @@ func Connect(c *gin.Context) {
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 {
@@ -256,6 +286,12 @@ func Disconnect(c *gin.Context) {
return
}
if command.Opts.Sessions {
result := DbSessions.Remove(getSessionId(c.Request))
successResponse(c, gin.H{"success": result})
return
}
conn := DB(c)
if conn == nil {
badRequest(c, errNotConnected)
@@ -268,6 +304,7 @@ func Disconnect(c *gin.Context) {
return
}
DbClient = nil
successResponse(c, gin.H{"success": true})
}
@@ -431,8 +468,14 @@ func GetHistory(c *gin.Context) {
// GetConnectionInfo renders information about current connection
func GetConnectionInfo(c *gin.Context) {
res, err := DB(c).Info()
conn := DB(c)
if err := conn.TestWithTimeout(5 * time.Second); err != nil {
badRequest(c, err)
return
}
res, err := conn.Info()
if err != nil {
badRequest(c, err)
return
@@ -444,6 +487,12 @@ func GetConnectionInfo(c *gin.Context) {
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()
@@ -462,8 +511,51 @@ func GetTableConstraints(c *gin.Context) {
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)
@@ -500,8 +592,9 @@ 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(c, bookmarks, err)
manager := bookmarks.NewManager(command.Opts.BookmarksDir)
ids, err := manager.ListIDs()
serveResult(c, ids, err)
}
// GetInfo renders the pgweb system information
@@ -511,6 +604,8 @@ func GetInfo(c *gin.Context) {
"features": gin.H{
"session_lock": command.Opts.LockSession,
"query_timeout": command.Opts.QueryTimeout,
"local_queries": QueryStore != nil,
"bookmarks_only": command.Opts.BookmarksOnly,
},
})
}
@@ -562,3 +657,78 @@ 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)
}
-87
View File
@@ -1,87 +0,0 @@
package api
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// Backend represents a third party configuration source
type Backend struct {
Endpoint string
Token string
PassHeaders []string
}
// BackendRequest represents a payload sent to the third-party source
type BackendRequest struct {
Resource string `json:"resource"`
Token string `json:"token"`
Headers map[string]string `json:"headers"`
}
// BackendCredential represents the third-party response
type BackendCredential struct {
DatabaseURL string `json:"database_url"`
}
// FetchCredential sends an authentication request to a third-party service
func (be Backend) FetchCredential(ctx context.Context, resource string, c *gin.Context) (*BackendCredential, error) {
logger.WithField("resource", resource).Debug("fetching database credential")
request := BackendRequest{
Resource: resource,
Token: be.Token,
Headers: map[string]string{},
}
// Pass white-listed client headers to the backend request
for _, name := range be.PassHeaders {
request.Headers[strings.ToLower(name)] = c.Request.Header.Get(name)
}
body, err := json.Marshal(request)
if err != nil {
logger.WithField("resource", resource).Error("backend request serialization error:", err)
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, be.Endpoint, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("content-type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
logger.WithField("resource", resource).Error("backend credential fetch failed:", err)
return nil, errBackendConnectError
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
err = fmt.Errorf("backend credential fetch received HTTP status code %v", resp.StatusCode)
logger.
WithField("resource", resource).
WithField("status", resp.StatusCode).
Error(err)
return nil, err
}
cred := &BackendCredential{}
if err := json.NewDecoder(resp.Body).Decode(cred); err != nil {
return nil, err
}
if cred.DatabaseURL == "" {
return nil, errConnStringRequired
}
return cred, nil
}
-2
View File
@@ -7,12 +7,10 @@ import (
var (
errNotConnected = errors.New("Not connected")
errNotPermitted = errors.New("Not permitted")
errConnStringRequired = errors.New("Connection string is required")
errInvalidConnString = errors.New("Invalid connection string")
errSessionRequired = errors.New("Session ID is required")
errSessionLocked = errors.New("Session is locked")
errURLRequired = errors.New("URL parameter is required")
errQueryRequired = errors.New("Query parameter is required")
errDatabaseNameRequired = errors.New("Database name is required")
errBackendConnectError = errors.New("Unable to connect to the auth backend")
)
+4 -3
View File
@@ -155,11 +155,12 @@ func assetContentType(name string) string {
// Send a query result to client
func serveResult(c *gin.Context, result interface{}, err interface{}) {
if err == nil {
successResponse(c, result)
} else {
if err != nil {
badRequest(c, err)
return
}
successResponse(c, result)
}
// Send successful response back to client
+26 -1
View File
@@ -8,6 +8,8 @@ import (
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"github.com/sosedoff/pgweb/pkg/command"
)
var (
@@ -29,6 +31,7 @@ func SetLogger(l *logrus.Logger) {
func RequestLogger(logger *logrus.Logger) gin.HandlerFunc {
debug := logger.Level > logrus.InfoLevel
logForwardedUser := command.Opts.LogForwardedUser
return func(c *gin.Context) {
start := time.Now()
@@ -54,10 +57,24 @@ func RequestLogger(logger *logrus.Logger) gin.HandlerFunc {
"status": status,
"method": c.Request.Method,
"remote_addr": c.ClientIP(),
"duration": latency,
"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()
}
@@ -88,3 +105,11 @@ func RequestLogger(logger *logrus.Logger) gin.HandlerFunc {
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
}
+31
View File
@@ -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}))
}
}
+11
View File
@@ -56,3 +56,14 @@ func corsMiddleware() gin.HandlerFunc {
c.Header("Access-Control-Allow-Origin", command.Opts.CorsOrigin)
}
}
func requireLocalQueries() gin.HandlerFunc {
return func(c *gin.Context) {
if QueryStore == nil {
badRequest(c, "local queries are disabled")
return
}
c.Next()
}
}
+13
View File
@@ -4,6 +4,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/sosedoff/pgweb/pkg/command"
"github.com/sosedoff/pgweb/pkg/metrics"
)
func SetupMiddlewares(group *gin.RouterGroup) {
@@ -34,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)
@@ -42,6 +44,7 @@ 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)
@@ -52,4 +55,14 @@ func SetupRoutes(router *gin.Engine) {
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()))
}
}
+15 -9
View File
@@ -7,6 +7,7 @@ import (
"github.com/sirupsen/logrus"
"github.com/sosedoff/pgweb/pkg/client"
"github.com/sosedoff/pgweb/pkg/metrics"
)
type SessionManager struct {
@@ -42,24 +43,29 @@ func (m *SessionManager) IDs() []string {
func (m *SessionManager) Sessions() map[string]*client.Client {
m.mu.Lock()
sessions := m.sessions
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()
c := m.sessions[id]
m.mu.Unlock()
defer m.mu.Unlock()
return c
return m.sessions[id]
}
func (m *SessionManager) Add(id string, conn *client.Client) {
m.mu.Lock()
defer m.mu.Unlock()
m.sessions[id] = conn
m.mu.Unlock()
metrics.SetSessionsCount(len(m.sessions))
}
func (m *SessionManager) Remove(id string) bool {
@@ -72,15 +78,15 @@ func (m *SessionManager) Remove(id string) bool {
delete(m.sessions, id)
}
metrics.SetSessionsCount(len(m.sessions))
return ok
}
func (m *SessionManager) Len() int {
m.mu.Lock()
sz := len(m.sessions)
m.mu.Unlock()
defer m.mu.Unlock()
return sz
return len(m.sessions)
}
func (m *SessionManager) Cleanup() int {
@@ -114,7 +120,7 @@ func (m *SessionManager) RunPeriodicCleanup() {
}
func (m *SessionManager) staleSessions() []string {
m.mu.TryLock()
m.mu.Lock()
defer m.mu.Unlock()
now := time.Now()
+8
View File
@@ -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"`
}
+25 -113
View File
@@ -1,13 +1,7 @@
package bookmarks
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/BurntSushi/toml"
"github.com/mitchellh/go-homedir"
"github.com/sosedoff/pgweb/pkg/command"
"github.com/sosedoff/pgweb/pkg/shared"
@@ -15,14 +9,18 @@ import (
// 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
SSLMode 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
@@ -32,110 +30,24 @@ func (b Bookmark) SSHInfoIsEmpty() bool {
// ConvertToOptions returns an options struct from connection details
func (b Bookmark) ConvertToOptions() command.Options {
user := b.User
if b.User == "" {
user = os.Getenv(b.UserVar)
}
pass := b.Password
if b.Password == "" {
pass = os.Getenv(b.PasswordVar)
}
return command.Options{
URL: b.URL,
Host: b.Host,
Port: b.Port,
User: b.User,
Pass: b.Password,
User: user,
Pass: pass,
DbName: b.Database,
SSLMode: b.SSLMode,
ReadOnly: b.ReadOnly,
}
}
func readServerConfig(path string) (Bookmark, error) {
bookmark := Bookmark{}
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)
}
// Path returns bookmarks storage path
func Path(overrideDir string) string {
if overrideDir == "" {
path, _ := homedir.Dir()
return fmt.Sprintf("%s/.pgweb/bookmarks", path)
}
return overrideDir
}
// ReadAll returns all available bookmarks
func ReadAll(path string) (map[string]Bookmark, error) {
results := map[string]Bookmark{}
files, err := os.ReadDir(path)
if err != nil {
return results, err
}
for _, file := range files {
if filepath.Ext(file.Name()) != ".toml" {
continue
}
fullPath := filepath.Join(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
}
// GetBookmark reads an existing bookmark
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
}
+77 -96
View File
@@ -8,115 +8,93 @@ 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, "toml: line 1: expected '.' or '=', but got 'e' 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.SSLMode)
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.SSLMode)
}
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.SSLMode)
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",
SSLMode: "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{
Host: "localhost",
Port: "8080",
User: "postgres",
}
b := Bookmark{SSH: nil}
assert.True(t, b.SSHInfoIsEmpty())
b = Bookmark{SSH: emptySSH}
b = Bookmark{SSH: info}
assert.True(t, b.SSHInfoIsEmpty())
})
b.SSH = populatedSSH
t.Run("populated", func(t *testing.T) {
info := &shared.SSHInfo{
Host: "localhost",
Port: "8080",
User: "postgres",
}
b := Bookmark{SSH: info}
assert.False(t, b.SSHInfoIsEmpty())
})
}
func Test_ConvertToOptions(t *testing.T) {
func TestBookmarkWithVarsConvertToOptions(t *testing.T) {
t.Run("literals set", func(t *testing.T) {
b := Bookmark{
User: "user",
UserVar: "",
Password: "password",
PasswordVar: "",
}
expOpt := command.Options{
User: "user",
Pass: "password",
}
opt := b.ConvertToOptions()
assert.Equal(t, expOpt, opt)
})
t.Run("all set", func(t *testing.T) {
b := Bookmark{
User: "user",
UserVar: "DB_USER",
Password: "password",
PasswordVar: "DB_PASSWORD",
}
expOpt := command.Options{
User: "user",
Pass: "password",
}
t.Setenv("DB_USER", "user123")
t.Setenv("DB_PASSWORD", "password123")
opt := b.ConvertToOptions()
assert.Equal(t, expOpt, opt)
})
t.Run("env vars set", func(t *testing.T) {
b := Bookmark{
User: "",
UserVar: "DB_USER",
Password: "",
PasswordVar: "DB_PASSWORD",
}
expOpt := command.Options{
User: "user123",
Pass: "password123",
}
t.Setenv("DB_USER", "user123")
t.Setenv("DB_PASSWORD", "password123")
opt := b.ConvertToOptions()
assert.Equal(t, expOpt, opt)
})
}
func TestBookmarkConvertToOptions(t *testing.T) {
b := Bookmark{
URL: "postgres://username:password@host:port/database?sslmode=disable",
Host: "localhost",
@@ -125,6 +103,7 @@ func Test_ConvertToOptions(t *testing.T) {
Password: "password",
Database: "mydatabase",
SSLMode: "disable",
ReadOnly: true,
}
expOpt := command.Options{
@@ -135,7 +114,9 @@ func Test_ConvertToOptions(t *testing.T) {
Pass: "password",
DbName: "mydatabase",
SSLMode: "disable",
ReadOnly: true,
}
opt := b.ConvertToOptions()
assert.Equal(t, expOpt, opt)
}
+152
View File
@@ -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)
}
+116
View File
@@ -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())
})
}
+121 -66
View File
@@ -1,11 +1,12 @@
package cli
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"os/signal"
"regexp"
"strings"
"syscall"
"time"
@@ -19,8 +20,10 @@ import (
"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 (
@@ -28,14 +31,11 @@ var (
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 / change data.
For proper read-only access please follow postgresql role management documentation.
------------------------------------------------------`
regexErrConnectionRefused = regexp.MustCompile(`(connection|actively) refused`)
regexErrAuthFailed = regexp.MustCompile(`authentication failed`)
--------------------------------------------------------------------------------
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() {
@@ -47,30 +47,14 @@ func exitWithMessage(message string) {
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.BuildStringFromOptions(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() {
@@ -82,7 +66,7 @@ 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()
}
@@ -91,35 +75,20 @@ func initClient() {
}
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...")
if err := cl.Test(); err != nil {
msg := err.Error()
// Check if we're trying to connect to the default database.
if command.Opts.DbName == "" && command.Opts.URL == "" {
// If database does not exist, allow user to connect from the UI.
if strings.Contains(msg, "database") && strings.Contains(msg, "does not exist") {
fmt.Println("Error:", msg)
abort, err := testClient(cl, int(retryCount), retryDelay)
if err != nil {
if abort {
exitWithMessage(err.Error())
} else {
return
}
// Do not bail if local server is not running.
if regexErrConnectionRefused.MatchString(msg) {
fmt.Println("Error:", msg)
return
}
// Do not bail if local auth is invalid
if regexErrAuthFailed.MatchString(msg) {
fmt.Println("Error:", msg)
return
}
}
exitWithMessage(msg)
}
if !command.Opts.Sessions {
@@ -173,9 +142,33 @@ func initOptions() {
}
}
configureLocalQueryStore()
printVersion()
}
func configureLocalQueryStore() {
if options.Sessions || options.QueriesDir == "" {
return
}
stat, err := os.Stat(options.QueriesDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
logger.Debugf("local queries directory %q does not exist, disabling feature", options.QueriesDir)
} else {
logger.Debugf("local queries feature disabled due to error: %v", err)
}
return
}
if !stat.IsDir() {
logger.Debugf("local queries path %q is not a directory", options.QueriesDir)
return
}
api.QueryStore = queries.NewStore(options.QueriesDir)
}
func configureLogger(opts command.Options) error {
if options.Debug {
logger.SetLevel(logrus.DebugLevel)
@@ -203,7 +196,7 @@ func printVersion() {
fmt.Println(command.VersionString())
}
func startServer() {
func startServer() *anyhttp.ServerCtx {
router := gin.New()
router.Use(api.RequestLogger(logger))
router.Use(gin.Recovery())
@@ -216,24 +209,37 @@ func startServer() {
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, syscall.SIGTERM)
<-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() {
@@ -255,6 +261,39 @@ func openPage() {
}
}
// 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() {
initOptions()
initClient()
@@ -282,7 +321,23 @@ func Run() {
}
}
startServer()
// 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())
}
}
+180 -14
View File
@@ -7,12 +7,14 @@ import (
"log"
neturl "net/url"
"reflect"
"regexp"
"strings"
"time"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"github.com/sosedoff/pgweb/pkg/bookmarks"
"github.com/sosedoff/pgweb/pkg/command"
"github.com/sosedoff/pgweb/pkg/connection"
"github.com/sosedoff/pgweb/pkg/history"
@@ -20,6 +22,18 @@ 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
@@ -27,21 +41,13 @@ type Client struct {
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 {
@@ -77,7 +83,10 @@ func New() (*Client, error) {
}
func NewFromUrl(url string, sshInfo *shared.SSHInfo) (*Client, error) {
var tunnel *Tunnel
var (
tunnel *Tunnel
err error
)
if sshInfo != nil {
if command.Opts.DisableSSH {
@@ -87,7 +96,7 @@ func NewFromUrl(url string, sshInfo *shared.SSHInfo) (*Client, error) {
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
@@ -137,6 +146,39 @@ func NewFromUrl(url string, sshInfo *shared.SSHInfo) (*Client, error) {
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)
@@ -160,11 +202,66 @@ func (client *Client) setServerVersion() {
}
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) {
@@ -285,6 +382,14 @@ 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) {
if client.serverType == cockroachType {
@@ -344,7 +449,9 @@ 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
}
@@ -359,6 +466,13 @@ func (client *Client) exec(query string, args ...interface{}) (*Result, error) {
Rows: []Row{
{affected},
},
Stats: &ResultStats{
ColumnsCount: 1,
RowsCount: 1,
QueryStartTime: queryStart.UTC(),
QueryFinishTime: queryFinish.UTC(),
QueryDuration: queryFinish.Sub(queryStart).Milliseconds(),
},
}
return &result, nil
@@ -376,7 +490,7 @@ 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 command.Opts.ReadOnly || client.readonly {
if err := client.SetReadOnlyMode(); err != nil {
return nil, err
}
@@ -395,7 +509,9 @@ func (client *Client) query(query string, args ...interface{}) (*Result, error)
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)
@@ -439,6 +555,14 @@ func (client *Client) query(query string, args ...interface{}) (*Result, error)
}
}
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
@@ -451,6 +575,7 @@ func (client *Client) Close() error {
}
defer func() {
client.closed = true
client.tunnel = nil
}()
if client.tunnel != nil {
@@ -512,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
}
+154 -4
View File
@@ -11,9 +11,10 @@ import (
"testing"
"time"
"github.com/sosedoff/pgweb/pkg/command"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/sosedoff/pgweb/pkg/command"
)
var (
@@ -56,7 +57,9 @@ func assertMatches(t *testing.T, expected, src []string) {
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
}
@@ -199,10 +202,50 @@ func testClientIdleTime(t *testing.T) {
}
func testTest(t *testing.T) {
assert.NoError(t, testClient.Test())
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",
@@ -218,6 +261,41 @@ func testInfo(t *testing.T) {
res, err := testClient.Info()
assert.NoError(t, err)
assert.Equal(t, expected, res.Columns)
})
t.Run("with restrictions", func(t *testing.T) {
expected := []string{
"session_user",
"current_user",
"current_database",
"current_schemas",
"version",
}
// Prepare a new user and database
testClient.db.MustExec("DROP DATABASE IF EXISTS testdb")
testClient.db.Exec("DROP OWNED BY IF EXISTS testuser") //nolint:all
testClient.db.MustExec("DROP ROLE IF EXISTS testuser")
testClient.db.MustExec("CREATE ROLE testuser WITH PASSWORD 'secret' LOGIN NOSUPERUSER NOINHERIT")
testClient.db.MustExec("CREATE DATABASE testdb OWNER testuser")
// Disable access to inet_ calls for new user
url := fmt.Sprintf("postgres://%s:@%s:%s/testdb?sslmode=disable", serverUser, serverHost, serverPort)
client, err := NewFromUrl(url, nil)
assert.NoError(t, err)
client.db.MustExec("REVOKE EXECUTE ON FUNCTION inet_client_addr() FROM PUBLIC")
assert.NoError(t, client.Close())
// Connect using new user
url = fmt.Sprintf("postgres://testuser:secret@%s:%s/testdb?sslmode=disable", serverHost, serverPort)
client, err = NewFromUrl(url, nil)
assert.NoError(t, err)
defer client.Close()
res, err := client.Info()
assert.NoError(t, err)
assert.Equal(t, expected, res.Columns)
})
}
func testActivity(t *testing.T) {
@@ -234,6 +312,12 @@ func testDatabases(t *testing.T) {
assertMatches(t, []string{"booktown", "postgres"}, res)
}
func testSchemas(t *testing.T) {
res, err := testClient.Schemas()
assert.NoError(t, err)
assert.Equal(t, []string{"public"}, res)
}
func testObjects(t *testing.T) {
res, err := testClient.Objects()
objects := ObjectsFromResult(res)
@@ -597,6 +681,68 @@ func testReadOnlyMode(t *testing.T) {
_, 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) {
@@ -617,6 +763,7 @@ func TestAll(t *testing.T) {
testInfo(t)
testActivity(t)
testDatabases(t)
testSchemas(t)
testObjects(t)
testTable(t)
testTableRows(t)
@@ -635,6 +782,9 @@ func TestAll(t *testing.T) {
testHistory(t)
testReadOnlyMode(t)
testDumpExport(t)
testTablesStats(t)
testConnContext(t)
testServerSettings(t)
teardownClient()
teardown(t, true)
+1 -1
View File
@@ -16,7 +16,7 @@ const (
)
var (
// BinaryEncodingFormat specifies the default serialization format of binary data
// BinaryCodec sets the serialization format of binary data
BinaryCodec = CodecBase64
)
+25 -9
View File
@@ -22,8 +22,18 @@ const (
)
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"`
@@ -35,6 +45,16 @@ type (
Pagination *Pagination `json:"pagination,omitempty"`
Columns []string `json:"columns"`
Rows []Row `json:"rows"`
Stats *ResultStats `json:"stats,omitempty"`
}
ResultStats struct {
ColumnsCount int `json:"columns_count"`
RowsCount int `json:"rows_count"`
RowsAffected int64 `json:"rows_affected"`
QueryStartTime time.Time `json:"query_start_time"`
QueryFinishTime time.Time `json:"query_finish_time"`
QueryDuration int64 `json:"query_duration_ms"`
}
Object struct {
@@ -94,16 +114,15 @@ func (res *Result) PostProcess() {
}
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
@@ -121,20 +140,17 @@ func (res *Result) CSV() []byte {
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
+27 -6
View File
@@ -2,6 +2,7 @@ package client
import (
"encoding/json"
"strings"
"testing"
"time"
@@ -51,17 +52,20 @@ func TestPostProcess(t *testing.T) {
func TestCSV(t *testing.T) {
result := Result{
Columns: []string{"id", "name", "email"},
Columns: []string{"id", "name", "email", "extra"},
Rows: []Row{
{1, "John", "john@example.com"},
{2, "Bob", "bob@example.com"},
{1, "John", "john@example.com", "data"},
{2, "Bob", "bob@example.com", nil},
},
}
expected := "id,name,email\n1,John,john@example.com\n2,Bob,bob@example.com\n"
output := string(result.CSV())
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, output)
assert.Equal(t, expected, string(result.CSV()))
}
func TestJSON(t *testing.T) {
@@ -116,3 +120,20 @@ func TestJSON(t *testing.T) {
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())
}
+11 -11
View File
@@ -8,6 +8,7 @@ import (
"net"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
@@ -35,8 +36,8 @@ 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 {
@@ -59,14 +60,13 @@ func parsePrivateKey(keyPath string, keyPass string) (ssh.Signer, error) {
}
signer, err := ssh.ParsePrivateKey(buff)
if err != nil {
if strings.Contains(err.Error(), "cannot decode encrypted private keys") {
if _, ok := err.(*ssh.PassphraseMissingError); ok {
if keyPass == "" {
return nil, errors.New("SSH key password is not provided")
return nil, errors.New("ssh key password is not provided")
}
return sshkeys.ParseEncryptedPrivateKey(buff, []byte(keyPass))
}
}
return signer, err
}
@@ -76,13 +76,13 @@ func makeConfig(info *shared.SSHInfo) (*ssh.ClientConfig, error) {
// Try to use user-provided key, fallback to system default key
keyPath := info.Key
if keyPath == "" {
keyPath = privateKeyPath()
keyPath = defaultKeyPath()
} else {
keyPath = expandKeyPath(keyPath)
}
if !fileExists(keyPath) {
return nil, errors.New("ssh public key not found at " + keyPath)
return nil, fmt.Errorf("ssh public key not found at path %q", keyPath)
}
// Append public key authentication method
@@ -130,11 +130,11 @@ 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()
+3 -3
View File
@@ -8,7 +8,7 @@ import (
var (
// List of keywords that are not allowed in read-only mode
reRestrictedKeywords = regexp.MustCompile(`(?mi)\s?(CREATE|INSERT|DROP|DELETE|TRUNCATE|GRANT|OPEN|IMPORT|COPY)\s`)
reRestrictedKeywords = regexp.MustCompile(`(?mi)\s?(CREATE|INSERT|UPDATE|DROP|DELETE|TRUNCATE|GRANT|OPEN|IMPORT|COPY)\s`)
// Comment regular expressions
reSlashComment = regexp.MustCompile(`(?m)/\*.+\*/`)
@@ -28,9 +28,9 @@ var (
// 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)
fmt.Sscanf(chunks[0], "%d", &major) //nolint
if len(chunks) > 1 {
fmt.Sscanf(chunks[1], "%d", &minor)
fmt.Sscanf(chunks[1], "%d", &minor) //nolint
}
return
}
+65 -2
View File
@@ -5,9 +5,12 @@ import (
"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"
)
@@ -21,16 +24,22 @@ type Options struct {
Debug bool `short:"d" long:"debug" description:"Enable debugging mode"`
LogLevel string `long:"log-level" description:"Logging level" default:"info"`
LogFormat string `long:"log-format" description:"Logging output format" default:"text"`
LogForwardedUser bool `long:"log-forwarded-user" description:"Log user information available in X-Forwarded-User/Email headers"`
URL string `long:"url" description:"Database connection string"`
Host string `long:"host" description:"Server hostname or IP" default:"localhost"`
Port int `long:"port" description:"Server port" default:"5432"`
User string `long:"user" description:"Database user"`
Pass string `long:"pass" description:"Password for user"`
Passfile string `long:"passfile" description:"Local passwords file location"`
DbName string `long:"db" description:"Database name"`
SSLMode string `long:"ssl" description:"SSL mode"`
SSLRootCert string `long:"ssl-rootcert" description:"SSL certificate authority file"`
SSLCert string `long:"ssl-cert" description:"SSL client certificate file"`
SSLKey string `long:"ssl-key" description:"SSL client certificate key file"`
OpenTimeout int `long:"open-timeout" description:"Maximum wait 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"`
@@ -42,6 +51,8 @@ type Options struct {
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"`
@@ -53,6 +64,9 @@ type Options struct {
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
@@ -79,6 +93,23 @@ func ParseOptions(args []string) (Options, error) {
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 == "" {
@@ -89,6 +120,10 @@ func ParseOptions(args []string) (Options, error) {
}
}
if getPrefixedEnvVar("BOOKMARKS_ONLY") != "" {
opts.BookmarksOnly = true
}
if getPrefixedEnvVar("SESSIONS") != "" {
opts.Sessions = true
}
@@ -108,7 +143,7 @@ func ParseOptions(args []string) (Options, error) {
opts.SSLMode = ""
}
if opts.Prefix != "" && !strings.Contains(opts.Prefix, "/") {
if opts.Prefix != "" && !strings.HasSuffix(opts.Prefix, "/") {
opts.Prefix = opts.Prefix + "/"
}
@@ -133,6 +168,34 @@ func ParseOptions(args []string) (Options, error) {
}
}
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
}
@@ -175,7 +238,7 @@ 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 + "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",
+59 -9
View File
@@ -1,13 +1,21 @@
package command
import (
"os"
"path/filepath"
"testing"
"github.com/mitchellh/go-homedir"
"github.com/stretchr/testify/assert"
)
func TestParseOptions(t *testing.T) {
// Test default behavior
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)
@@ -20,28 +28,70 @@ func TestParseOptions(t *testing.T) {
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)
})
// Test sessions
opts, err = ParseOptions([]string{"--sessions", "1"})
t.Run("sessions", func(t *testing.T) {
opts, err := ParseOptions([]string{"--sessions", "1"})
assert.NoError(t, err)
assert.Equal(t, true, opts.Sessions)
})
// Test url prefix
opts, err = ParseOptions([]string{"--prefix", "pgweb"})
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)
})
// Test connect backend options
opts, err = ParseOptions([]string{"--connect-backend", "test"})
t.Run("connect backend", func(t *testing.T) {
_, err := ParseOptions([]string{"--connect-backend", "test"})
assert.EqualError(t, err, "--sessions flag must be set")
opts, err = ParseOptions([]string{"--connect-backend", "test", "--sessions"})
_, err = ParseOptions([]string{"--connect-backend", "test", "--sessions"})
assert.EqualError(t, err, "--connect-token flag must be set")
opts, err = ParseOptions([]string{"--connect-backend", "test", "--sessions", "--connect-token", "token"})
_, 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")
})
}
+1 -1
View File
@@ -8,7 +8,7 @@ import (
const (
// Version is the current Pgweb application version
Version = "0.12.0"
Version = "0.17.0"
)
var (
+92
View File
@@ -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
}
@@ -1,4 +1,4 @@
package api
package connect
import (
"context"
@@ -9,6 +9,7 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
)
@@ -17,8 +18,8 @@ func TestBackendFetchCredential(t *testing.T) {
name string
backend Backend
resourceName string
cred *BackendCredential
reqCtx *gin.Context
cred *Credential
headers http.Header
ctx func() (context.Context, context.CancelFunc)
err error
}{
@@ -33,12 +34,12 @@ func TestBackendFetchCredential(t *testing.T) {
ctx: func() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), time.Millisecond*100)
},
err: errors.New("Unable to connect to the auth backend"),
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"),
err: errors.New("connection string is required"),
},
{
name: "Missing header",
@@ -51,19 +52,15 @@ func TestBackendFetchCredential(t *testing.T) {
Endpoint: "http://localhost:5555/pass-header",
PassHeaders: []string{"x-foo"},
},
reqCtx: &gin.Context{
Request: &http.Request{
Header: http.Header{
headers: http.Header{
"X-Foo": []string{"bar"},
},
},
},
cred: &BackendCredential{DatabaseURL: "postgres://hostname/bar"},
cred: &Credential{DatabaseURL: "postgres://hostname/bar"},
},
{
name: "Success",
backend: Backend{Endpoint: "http://localhost:5555/success"},
cred: &BackendCredential{DatabaseURL: "postgres://hostname/dbname"},
cred: &Credential{DatabaseURL: "postgres://hostname/dbname"},
},
}
@@ -73,6 +70,8 @@ func TestBackendFetchCredential(t *testing.T) {
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 {
@@ -80,14 +79,7 @@ func TestBackendFetchCredential(t *testing.T) {
}
defer cancel()
reqCtx := ex.reqCtx
if reqCtx == nil {
reqCtx = &gin.Context{
Request: &http.Request{},
}
}
cred, err := ex.backend.FetchCredential(ctx, ex.resourceName, reqCtx)
cred, err := ex.backend.FetchCredential(ctx, ex.resourceName, ex.headers)
assert.Equal(t, ex.err, err)
assert.Equal(t, ex.cred, cred)
})
@@ -117,7 +109,7 @@ func startTestBackend(ctx context.Context, listenAddr string) {
})
router.POST("/pass-header", func(c *gin.Context) {
req := BackendRequest{}
req := Request{}
if err := c.BindJSON(&req); err != nil {
panic(err)
}
+20
View File
@@ -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"`
}
+60
View File
@@ -6,8 +6,11 @@ import (
neturl "net/url"
"os"
"os/user"
"strconv"
"strings"
"github.com/jackc/pgpassfile"
"github.com/sosedoff/pgweb/pkg/command"
)
@@ -76,6 +79,22 @@ func FormatURL(opts command.Options) (string, error) {
}
}
// When password is not provided, look it up from a .pgpass file
if uri.User != nil {
pass, _ := uri.User.Password()
if pass == "" && opts.Passfile != "" {
pass = lookupPassword(opts, uri)
if pass != "" {
uri.User = neturl.UserPassword(uri.User.Username(), pass)
}
}
}
// Configure default connect timeout
if opts.OpenTimeout > 0 {
params["connect_timeout"] = strconv.Itoa(opts.OpenTimeout)
}
// Rebuild query params
query := neturl.Values{}
for k, v := range params {
@@ -125,6 +144,16 @@ func BuildStringFromOptions(opts command.Options) (string, error) {
query.Add("sslrootcert", opts.SSLRootCert)
}
// Grab password from .pgpass file if it's available
if opts.Pass == "" && opts.Passfile != "" {
opts.Pass = lookupPassword(opts, nil)
}
// Configure default connect timeout
if opts.OpenTimeout > 0 {
query.Add("connect_timeout", strconv.Itoa(opts.OpenTimeout))
}
url := neturl.URL{
Scheme: "postgres",
Host: fmt.Sprintf("%v:%v", opts.Host, opts.Port),
@@ -135,3 +164,34 @@ func BuildStringFromOptions(opts command.Options) (string, error) {
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,
)
}
+244 -134
View File
@@ -10,7 +10,192 @@ import (
"github.com/stretchr/testify/assert"
)
func Test_Invalid_Url(t *testing.T) {
func TestBuildStringFromOptions(t *testing.T) {
t.Run("valid url", func(t *testing.T) {
url := "postgres://myhost/database"
str, err := BuildStringFromOptions(command.Options{URL: url})
assert.NoError(t, err)
assert.Equal(t, url, str)
})
t.Run("with sslmode param", func(t *testing.T) {
str, err := BuildStringFromOptions(command.Options{
URL: "postgres://myhost/database",
SSLMode: "disable",
})
assert.NoError(t, err)
assert.Equal(t, "postgres://myhost/database?sslmode=disable", str)
})
t.Run("sets sslmode param if not set", func(t *testing.T) {
str, err := BuildStringFromOptions(command.Options{
URL: "postgres://localhost/database",
})
assert.NoError(t, err)
assert.Equal(t, "postgres://localhost/database?sslmode=disable", str)
str, err = BuildStringFromOptions(command.Options{
URL: "postgres://127.0.0.1/database",
})
assert.NoError(t, err)
assert.Equal(t, "postgres://127.0.0.1/database?sslmode=disable", str)
})
t.Run("sslmode as an option", func(t *testing.T) {
str, err := BuildStringFromOptions(command.Options{
URL: "postgres://localhost/database",
SSLMode: "require",
})
assert.NoError(t, err)
assert.Equal(t, "postgres://localhost/database?sslmode=require", str)
str, err = BuildStringFromOptions(command.Options{
URL: "postgres://127.0.0.1/database",
SSLMode: "require",
})
assert.NoError(t, err)
assert.Equal(t, "postgres://127.0.0.1/database?sslmode=require", str)
})
t.Run("localhost and sslmode flag", func(t *testing.T) {
str, err := BuildStringFromOptions(command.Options{
URL: "postgres://localhost/database?sslmode=require",
})
assert.NoError(t, err)
assert.Equal(t, "postgres://localhost/database?sslmode=require", str)
str, err = BuildStringFromOptions(command.Options{
URL: "postgres://127.0.0.1/database?sslmode=require",
})
assert.NoError(t, err)
assert.Equal(t, "postgres://127.0.0.1/database?sslmode=require", str)
})
t.Run("extended options", func(t *testing.T) {
str, err := BuildStringFromOptions(command.Options{
URL: "postgres://localhost/database?sslmode=require&sslcert=cert&sslkey=key&sslrootcert=ca",
})
assert.NoError(t, err)
assert.Equal(t, "postgres://localhost/database?sslcert=cert&sslkey=key&sslmode=require&sslrootcert=ca", str)
})
t.Run("from flags", func(t *testing.T) {
str, err := BuildStringFromOptions(command.Options{
Host: "host",
Port: 5432,
User: "user",
Pass: "password",
DbName: "db",
})
assert.NoError(t, err)
assert.Equal(t, "postgres://user:password@host:5432/db", str)
})
t.Run("localhost", func(t *testing.T) {
opts := command.Options{
Host: "localhost",
Port: 5432,
User: "user",
Pass: "password",
DbName: "db",
}
str, err := BuildStringFromOptions(opts)
assert.NoError(t, err)
assert.Equal(t, "postgres://user:password@localhost:5432/db?sslmode=disable", str)
opts.Host = "127.0.0.1"
str, err = BuildStringFromOptions(opts)
assert.NoError(t, err)
assert.Equal(t, "postgres://user:password@127.0.0.1:5432/db?sslmode=disable", str)
})
t.Run("localhost and ssl", func(t *testing.T) {
opts := command.Options{
Host: "localhost",
Port: 5432,
User: "user",
Pass: "password",
DbName: "db",
SSLMode: "require",
SSLKey: "keyPath",
SSLCert: "certPath",
SSLRootCert: "caPath",
}
str, err := BuildStringFromOptions(opts)
assert.NoError(t, err)
assert.Equal(t, "postgres://user:password@localhost:5432/db?sslcert=certPath&sslkey=keyPath&sslmode=require&sslrootcert=caPath", str)
})
t.Run("no user", func(t *testing.T) {
opts := command.Options{Host: "host", Port: 5432, DbName: "db"}
u, _ := user.Current()
str, err := BuildStringFromOptions(opts)
userAndPass := url.UserPassword(u.Username, "").String()
assert.NoError(t, err)
assert.Equal(t, fmt.Sprintf("postgres://%s@host:5432/db", userAndPass), str)
})
t.Run("port", func(t *testing.T) {
opts := command.Options{Host: "host", User: "user", Port: 5000, DbName: "db"}
str, err := BuildStringFromOptions(opts)
assert.NoError(t, err)
assert.Equal(t, "postgres://user:@host:5000/db", str)
})
t.Run("with pgpass", func(t *testing.T) {
opts := command.Options{
Host: "localhost",
Port: 5432,
User: "username",
DbName: "dbname",
Passfile: "../../data/passfile",
}
str, err := BuildStringFromOptions(opts)
assert.NoError(t, err)
assert.Equal(t, "postgres://username:password@localhost:5432/dbname?sslmode=disable", str)
opts.User = "foobar"
str, err = BuildStringFromOptions(opts)
assert.NoError(t, err)
assert.Equal(t, "postgres://foobar:@localhost:5432/dbname?sslmode=disable", str)
opts.Host = "127.0.0.1"
opts.DbName = "foobar2"
str, err = BuildStringFromOptions(opts)
assert.NoError(t, err)
assert.Equal(t, "postgres://foobar:password2@127.0.0.1:5432/foobar2?sslmode=disable", str)
})
t.Run("with connection timeout", func(t *testing.T) {
opts := command.Options{
URL: "postgres://user:pass@localhost:5432/dbname",
OpenTimeout: 30,
}
str, err := BuildStringFromOptions(opts)
assert.NoError(t, err)
assert.Equal(t, "postgres://user:pass@localhost:5432/dbname?connect_timeout=30&sslmode=disable", str)
opts = command.Options{
Host: "localhost",
Port: 5432,
User: "username",
DbName: "dbname",
OpenTimeout: 30,
}
str, err = BuildStringFromOptions(opts)
assert.NoError(t, err)
assert.Equal(t, "postgres://username:@localhost:5432/dbname?connect_timeout=30&sslmode=disable", str)
})
t.Run("invalid url", func(t *testing.T) {
opts := command.Options{}
examples := []string{
"postgre://foobar",
@@ -26,147 +211,72 @@ func Test_Invalid_Url(t *testing.T) {
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) {
url := "postgres://myhost/database"
str, err := BuildStringFromOptions(command.Options{URL: url})
assert.Equal(t, nil, err)
assert.Equal(t, url, str)
}
func Test_Url_And_Ssl_Flag(t *testing.T) {
str, err := BuildStringFromOptions(command.Options{
URL: "postgres://myhost/database",
SSLMode: "disable",
})
assert.Equal(t, nil, err)
assert.Equal(t, "postgres://myhost/database?sslmode=disable", str)
}
func Test_Localhost_Url_And_No_Ssl_Flag(t *testing.T) {
str, err := BuildStringFromOptions(command.Options{
URL: "postgres://localhost/database",
})
assert.Equal(t, nil, err)
assert.Equal(t, "postgres://localhost/database?sslmode=disable", str)
str, err = BuildStringFromOptions(command.Options{
URL: "postgres://127.0.0.1/database",
})
assert.Equal(t, nil, 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 := BuildStringFromOptions(command.Options{
URL: "postgres://localhost/database",
SSLMode: "require",
})
assert.Equal(t, nil, err)
assert.Equal(t, "postgres://localhost/database?sslmode=require", str)
str, err = BuildStringFromOptions(command.Options{
URL: "postgres://127.0.0.1/database",
SSLMode: "require",
})
assert.Equal(t, nil, 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 := BuildStringFromOptions(command.Options{
URL: "postgres://localhost/database?sslmode=require",
})
assert.Equal(t, nil, err)
assert.Equal(t, "postgres://localhost/database?sslmode=require", str)
str, err = BuildStringFromOptions(command.Options{
URL: "postgres://127.0.0.1/database?sslmode=require",
})
assert.Equal(t, nil, err)
assert.Equal(t, "postgres://127.0.0.1/database?sslmode=require", str)
}
func Test_ExtendedSSLFlags(t *testing.T) {
str, err := BuildStringFromOptions(command.Options{
URL: "postgres://localhost/database?sslmode=require&sslcert=cert&sslkey=key&sslrootcert=ca",
})
assert.Equal(t, nil, 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 := BuildStringFromOptions(command.Options{
Host: "host",
Port: 5432,
User: "user",
Pass: "password",
DbName: "db",
})
assert.Equal(t, nil, err)
assert.Equal(t, "postgres://user:password@host:5432/db", str)
}
func Test_Localhost(t *testing.T) {
opts := command.Options{
Host: "localhost",
Port: 5432,
User: "user",
Pass: "password",
DbName: "db",
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",
},
}
str, err := BuildStringFromOptions(opts)
assert.Equal(t, nil, err)
assert.Equal(t, "postgres://user:password@localhost:5432/db?sslmode=disable", str)
for _, ex := range examples {
t.Run(ex.name, func(t *testing.T) {
str, err := FormatURL(ex.input)
opts.Host = "127.0.0.1"
str, err = BuildStringFromOptions(opts)
assert.Equal(t, nil, err)
assert.Equal(t, "postgres://user:password@127.0.0.1:5432/db?sslmode=disable", str)
}
func Test_Localhost_And_Ssl(t *testing.T) {
opts := command.Options{
Host: "localhost",
Port: 5432,
User: "user",
Pass: "password",
DbName: "db",
SSLMode: "require",
SSLKey: "keyPath",
SSLCert: "certPath",
SSLRootCert: "caPath",
if ex.err != "" {
assert.Error(t, err)
assert.Contains(t, err.Error(), ex.err)
}
assert.Equal(t, ex.result, str)
})
}
str, err := BuildStringFromOptions(opts)
assert.Equal(t, nil, 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) {
opts := command.Options{Host: "host", Port: 5432, DbName: "db"}
u, _ := user.Current()
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", userAndPass), str)
}
func Test_Port(t *testing.T) {
opts := command.Options{Host: "host", User: "user", Port: 5000, DbName: "db"}
str, err := BuildStringFromOptions(opts)
assert.Equal(t, nil, err)
assert.Equal(t, "postgres://user:@host:5000/db", str)
}
func Test_Blank(t *testing.T) {
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"}))
+26
View File
@@ -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(),
}
}
+55
View File
@@ -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))
}
+14
View File
@@ -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)
}
+43
View File
@@ -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
}
+41
View File
@@ -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())
}
+148
View File
@@ -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")
}
+146
View File
@@ -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))
})
}
}
+23
View File
@@ -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)
}
+77
View File
@@ -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),
},
}
}
+88
View File
@@ -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
}
+71
View File
@@ -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)
}
})
}
}
+9
View File
@@ -14,6 +14,9 @@ var (
//go:embed sql/info.sql
Info string
//go:embed sql/info_simple.sql
InfoSimple string
//go:embed sql/estimated_row_count.sql
EstimatedTableRowCount string
@@ -38,9 +41,15 @@ var (
//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 WHERE datname = current_database()",
+6
View File
@@ -0,0 +1,6 @@
SELECT
session_user,
current_user,
current_database(),
current_schemas(false),
version()
+3 -3
View File
@@ -20,7 +20,7 @@ WITH all_objects AS (
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 !~ '^pg_(toast|temp)'
AND n.nspname NOT IN ('information_schema', 'pg_catalog')
AND has_schema_privilege(n.nspname, 'USAGE')
@@ -38,8 +38,8 @@ WITH all_objects AS (
JOIN
pg_catalog.pg_proc p ON p.pronamespace = n.oid
WHERE
n.nspname !~ '^pg_toast'
n.nspname !~ '^pg_(toast|temp)'
AND n.nspname NOT IN ('information_schema', 'pg_catalog')
)
SELECT * FROM all_objects
ORDER BY 1, 2
ORDER BY 2, 3
+3
View File
@@ -2,5 +2,8 @@ SELECT
schema_name
FROM
information_schema.schemata
WHERE
schema_name NOT IN ('information_schema', 'pg_catalog')
AND schema_name !~ '^pg_(toast|temp)'
ORDER BY
schema_name ASC
+1
View File
@@ -0,0 +1 @@
SELECT * FROM pg_settings
+1
View File
@@ -10,5 +10,6 @@ JOIN
WHERE
n.nspname = $1
AND relname = $2
AND contype != 'n'
ORDER BY
contype DESC
+1 -1
View File
@@ -1,6 +1,6 @@
SELECT
indexname AS index_name,
pg_size_pretty(pg_table_size((schemaname || '.' || indexname)::regclass)) AS index_size,
pg_size_pretty(pg_table_size(('"' || schemaname || '"."' || indexname || '"')::regclass)) AS index_size,
indexdef AS index_definition
FROM
pg_indexes
+44
View File
@@ -0,0 +1,44 @@
WITH columns_counts AS (
SELECT table_schema, table_name, COUNT(1) AS num
FROM information_schema.columns
GROUP BY table_schema, table_name
),
indexes_counts AS (
SELECT schemaname, tablename, COUNT(1) AS num
FROM pg_indexes
GROUP BY schemaname, tablename
)
SELECT
tables.schemaname AS schema_name,
tables.relname AS table_name,
pg_size_pretty(pg_total_relation_size(tables.relid)) AS total_size,
pg_size_pretty(pg_table_size(tables.relid)) AS data_size,
pg_size_pretty(pg_indexes_size(tables.relid)) AS index_size,
pg_class.reltuples AS estimated_rows_count,
CASE
WHEN pg_class.reltuples >= 0 AND pg_class.reltuples < 1000
THEN pg_class.reltuples::text
WHEN pg_class.reltuples >= 1000 AND pg_class.reltuples < 1000000
THEN ROUND((pg_class.reltuples / 1000))::text || 'K'
WHEN pg_class.reltuples >= 1000000
THEN ROUND(pg_class.reltuples / 1000000)::text || 'M'
END AS estimated_rows,
CASE
WHEN pg_class.reltuples > 1000
THEN ROUND(pg_indexes_size(tables.relid)::numeric / pg_table_size(tables.relid), 2)
END AS index_to_data_ratio,
indexes_counts.num AS indexes_count,
columns_counts.num AS columns_count
FROM
pg_catalog.pg_statio_user_tables AS tables
LEFT JOIN pg_class
ON pg_class.oid = tables.relid
LEFT JOIN indexes_counts
ON indexes_counts.schemaname = tables.schemaname
AND indexes_counts.tablename = tables.relname
LEFT JOIN columns_counts
ON columns_counts.table_schema = tables.schemaname
AND columns_counts.table_name = tables.relname
ORDER BY
pg_total_relation_size(tables.relid) DESC,
pg_table_size(tables.relid) DESC
+67
View File
@@ -357,6 +357,7 @@
#input .actions #query_progress {
display: none;
float: left;
font-size: 12px;
line-height: 30px;
height: 30px;
color: #aaa;
@@ -625,6 +626,72 @@
float: left;
}
#content_modal {
display: none;
width: 60%;
height: 400px;
position: fixed;
top: 20%;
left: 20%;
background: #fff;
border: 1px solid #ccc;
box-shadow: #ddd 0 0 20px;
}
#content_modal .content {
border: 0px none;
position: relative;
padding: 8px;
white-space: break-spaces;
background: #fff;
overflow-y: scroll;
height: 366px;
box-sizing: border-box;
}
#content_modal .title {
font-weight: bold;
line-height: 24px;
background: #f5f5f5;
padding: 4px 4px 4px 8px;
}
#content_modal .actions {
float: right;
}
#content_modal .actions .fa {
float: right;
width: 24px;
height: 24px;
line-height: 24px;
font-size: 13px;
text-align: center;
background: #fff;
border: 1px solid #ddd;
border-radius: 3px;
cursor: pointer;
margin-left: 4px;
}
#content_modal .actions .fa:hover {
border-color: #999;
box-shadow: #eee 0 0 5px;
}
#error_banner {
line-height: 30px;
text-align: center;
background-color: #be2740;
color: #fff;
display: none;
position: fixed;
bottom: 0px;
left: 0px;
right: 0px;
height: 30px;
}
/* -------------------------------------------------------------------------- */
#custom_query {
+35 -3
View File
@@ -87,6 +87,13 @@
<li><a href="#" id="analyze">Analyze Query</a></li>
</ul>
</div>
<div id="load-query-dropdown" class="btn-group left" style="display: none">
<button id="load-local-query" type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown" disabled="disabled">
Template <span class="caret"></span>
</button>
<ul class="dropdown-menu" role="menu">
</ul>
</div>
<div id="query_progress">Please wait, query is executing...</div>
<div class="pull-right">
<span id="result-rows-count"></span>
@@ -139,6 +146,17 @@
</div>
</div>
<div id="content_modal">
<div class="title">
Cell Content
<div class="actions">
<i class="fa fa-times content-modal-action" data-action="close"></i>
<i class="fa fa-copy content-modal-action" data-action="copy"></i>
</div>
</div>
<pre class="content"></pre>
</div>
<div id="connection_window">
<div class="connection-settings">
<div class="header">
@@ -171,23 +189,29 @@
</div>
</div>
<div class="connection-standard-group">
<div class="connection-bookmarks-group">
<div class="form-group bookmarks">
<label class="col-sm-3 control-label">Bookmark</label>
<div class="col-sm-9">
<select class="form-control" id="connection_bookmarks"></select>
</div>
</div>
</div>
<div class="connection-standard-group">
<div class="form-group">
<label class="col-sm-3 control-label">Host</label>
<div class="col-sm-7">
<div class="col-sm-9">
<div class="row">
<div class="col-sm-9 col-xs-9">
<input type="text" id="pg_host" class="form-control" />
</div>
<div class="col-sm-2 no-left-padding">
<div class="col-sm-3 col-xs-3 no-left-padding">
<input type="text" id="pg_port" class="form-control" placeholder="5432" />
</div>
</div>
</div>
</div>
<div class="form-group">
<label class="col-sm-3 control-label">Username</label>
@@ -274,6 +298,7 @@
<div id="tables_context_menu">
<ul class="dropdown-menu" role="menu">
<li><a href="#" data-action="copy">Copy Table Name</a></li>
<li><a href="#" data-action="analyze">Analyze Table</a></li>
<li class="divider"></li>
<li><a href="#" data-action="export" data-format="json">Export to JSON</a></li>
<li><a href="#" data-action="export" data-format="csv">Export to CSV</a></li>
@@ -299,6 +324,11 @@
</div>
<div id="current_database_context_menu">
<ul class="dropdown-menu" role="menu">
<li><a href="#" data-action="show_db_stats">Show Database Stats</a></li>
<li><a href="#" data-action="download_db_stats">Download Database Stats</a></li>
<li class="divider"></li>
<li><a href="#" data-action="server_settings">Show Server Settings</a></li>
<li class="divider"></li>
<li><a href="#" data-action="export">Export SQL dump</a></li>
</ul>
</div>
@@ -312,9 +342,11 @@
</div>
<div id="results_row_menu">
<ul class="dropdown-menu" role="menu">
<li><a href="#" data-action="display_value">Display Value</a></li>
<li><a href="#" data-action="copy_value">Copy Value</a></li>
<li><a href="#" data-action="filter_by_value">Filter Rows By Value</a></li>
</ul>
</div>
<div id="error_banner"></div>
</body>
</html>
+218 -59
View File
@@ -75,23 +75,39 @@ function apiCall(method, path, params, cb) {
},
success: cb,
error: function(xhr, status, data) {
if (status == "timeout") {
switch(status) {
case "error":
if (xhr.readyState == 0) { // 0 = UNSENT
showErrorBanner("Sorry, something went wrong with your request. Refresh the page and try again!");
}
break;
case "timeout":
return cb({ error: "Query timeout after " + timeout + "s" });
}
cb(jQuery.parseJSON(xhr.responseText));
var responseText;
try {
responseText = jQuery.parseJSON(xhr.responseText);
}
catch {
responseText = { error: "Failed to parse the JSON response." };
}
cb(responseText);
}
});
}
function getInfo(cb) { apiCall("get", "/info", {}, cb); }
function getConnection(cb) { apiCall("get", "/connection", {}, cb); }
function getServerSettings(cb) { apiCall("get", "/server_settings", {}, cb); }
function getSchemas(cb) { apiCall("get", "/schemas", {}, cb); }
function getObjects(cb) { apiCall("get", "/objects", {}, cb); }
function getTables(cb) { apiCall("get", "/tables", {}, cb); }
function getTableRows(table, opts, cb) { apiCall("get", "/tables/" + table + "/rows", opts, cb); }
function getTableStructure(table, opts, cb) { apiCall("get", "/tables/" + table, opts, cb); }
function getTableIndexes(table, cb) { apiCall("get", "/tables/" + table + "/indexes", {}, cb); }
function getTableConstraints(table, cb) { apiCall("get", "/tables/" + table + "/constraints", {}, cb); }
function getTablesStats(cb) { apiCall("get", "/tables_stats", {}, cb); }
function getFunction(id, cb) { apiCall("get", "/functions/" + id, {}, cb); }
function getHistory(cb) { apiCall("get", "/history", {}, cb); }
function getBookmarks(cb) { apiCall("get", "/bookmarks", {}, cb); }
@@ -104,6 +120,18 @@ function encodeQuery(query) {
return Base64.encode(query).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ".");
}
function showErrorBanner(text) {
if (window.errBannerTimeout != null) {
clearTimeout(window.errBannerTimeout);
}
window.errBannerTimeout = setTimeout(function() {
$("#error_banner").fadeOut("fast").text("");
}, 3000);
$("#error_banner").text(text).show();
}
function buildSchemaSection(name, objects) {
var section = "";
@@ -158,22 +186,69 @@ function buildSchemaSection(name, objects) {
return section;
}
function loadLocalQueries() {
if (!appFeatures.local_queries) return;
$("body").on("click", "a.load-local-query", function(e) {
var id = $(this).data("id");
apiCall("get", "/local_queries/" + id, {}, function(resp) {
editor.setValue(resp.query);
editor.clearSelection();
});
});
apiCall("get", "/local_queries", {}, function(resp) {
if (resp.error) return;
var container = $("#load-query-dropdown").find(".dropdown-menu");
resp.forEach(function(item) {
var title = item.title || item.id;
$("<li><a href='#' class='load-local-query' data-id='" + item.id + "'>" + title + "</a></li>").appendTo(container);
});
if (resp.length > 0) $("#load-local-query").prop("disabled", "");
$("#load-query-dropdown").show();
});
}
function loadSchemas() {
$("#objects").html("");
getObjects(function(data) {
if (Object.keys(data).length == 0) {
data["public"] = {
var emptyObjectList = function() {
return {
table: [],
view: [],
materialized_view: [],
function: [],
sequence: []
};
}
}
for (schema in data) {
$(buildSchemaSection(schema, data[schema])).appendTo("#objects");
getSchemas(function(schemasData) {
if (schemasData.error) {
alert("Error while fetching schemas: " + schemasData.error);
return;
}
getObjects(function(data) {
if (data.error) {
alert("Error while fetching database objects: " + data.error);
return;
}
if (Object.keys(data).length == 0) {
data["public"] = emptyObjectList();
}
for (schemaName of schemasData) {
// Allow users to see empty schemas if we dont have any objects in them
if (!data[schemaName]) {
data[schemaName] = emptyObjectList();
}
$(buildSchemaSection(schemaName, data[schemaName])).appendTo("#objects");
}
if (Object.keys(data).length == 1) {
@@ -200,6 +275,7 @@ function loadSchemas() {
bindContextMenus();
});
});
}
function escapeHtml(str) {
@@ -265,6 +341,12 @@ function performTableAction(table, action, el) {
case "copy":
copyToClipboard(table.split('.')[1]);
break;
case "analyze":
executeQuery("ANALYZE " + table, function(data) {
if (data.error) alert(data.error);
resetTable();
});
break;
}
}
@@ -349,7 +431,11 @@ function buildTable(results, sortColumn, sortOrder, options) {
if (results.rows.length == 0) {
$("#results_header").html("");
$("#results_body").html("<tr><td>No records found</td></tr>");
if (results.stats) {
$("#result-rows-count").html(results.stats.query_duration_ms + " ms");
} else {
$("#result-rows-count").html("");
}
$("#results").addClass("empty");
return;
}
@@ -359,9 +445,8 @@ function buildTable(results, sortColumn, sortOrder, options) {
results.columns.forEach(function(col) {
if (col === sortColumn) {
cols += "<th class='table-header-col active' data-name='" + col + "'" + "data-order=" + sortOrder + ">" + col + "&nbsp;" + sortArrow(sortOrder) + "</th>";
}
else {
cols += "<th class='table-header-col active' data-name='" + col + "' data-order=" + sortOrder + ">" + col + "&nbsp;" + sortArrow(sortOrder) + "</th>";
} else {
cols += "<th class='table-header-col' data-name='" + col + "'>" + col + "</th>";
}
});
@@ -394,7 +479,11 @@ function buildTable(results, sortColumn, sortOrder, options) {
$("#results_body").html(rows);
// Show number of rows rendered on the page
if (results.stats) {
$("#result-rows-count").html(results.stats.rows_count + " rows in " + results.stats.query_duration_ms + " ms");
} else {
$("#result-rows-count").html(results.rows.length + " rows");
}
}
function setCurrentTab(id) {
@@ -575,6 +664,32 @@ function showPaginatedTableContent() {
showTableContent(sortColumn, sortOrder);
}
function showDatabaseStats() {
getTablesStats(function(data) {
buildTable(data);
setCurrentTab("table_structure");
$("#input").hide();
$("#body").prop("class", "full");
$("#results").addClass("no-crop");
});
}
function downloadDatabaseStats() {
openInNewWindow("api/tables_stats", { format: "csv", export: "true" });
}
function showServerSettings() {
getServerSettings(function(data) {
buildTable(data);
setCurrentTab("table_content");
$("#input").hide();
$("#body").prop("class", "full");
$("#results").addClass("no-crop");
});
}
function showTableStructure() {
var name = getCurrentObject().name;
@@ -682,13 +797,13 @@ function showActivityPanel() {
}
function showQueryProgressMessage() {
$("#run, #explain-dropdown-toggle, #csv, #json, #xml").prop("disabled", true);
$("#run, #explain-dropdown-toggle, #csv, #json, #xml, #load-local-query").prop("disabled", true);
$("#explain-dropdown").removeClass("open");
$("#query_progress").show();
}
function hideQueryProgressMessage() {
$("#run, #explain-dropdown-toggle, #csv, #json, #xml").prop("disabled", false);
$("#run, #explain-dropdown-toggle, #csv, #json, #xml, #load-local-query").prop("disabled", false);
$("#query_progress").hide();
}
@@ -1000,18 +1115,19 @@ function getLatestReleaseInfo(current) {
function showConnectionSettings() {
// Show the current postgres version
$(".connection-settings .version").text("v" + appInfo.version).show();
$("#connection_window").show();
initConnectionWindow();
// Check github release page for updates
getLatestReleaseInfo(appInfo);
getBookmarks(function(data) {
// Do not add any bookmarks if we've got an error
if (data.error) {
console.log("Cant get bookmarks:", data.error);
console.log("Error while fetching bookmarks:", data.error);
return;
}
if (Object.keys(data).length > 0) {
if (data.length > 0) {
// Set bookmarks in global var
bookmarks = data;
@@ -1019,21 +1135,40 @@ function showConnectionSettings() {
$("#connection_bookmarks").html("");
// Add blank option
$("<option value=''></option>").appendTo("#connection_bookmarks");
$("<option value=''>Select a bookmarked database to connect to</option>").appendTo("#connection_bookmarks");
// Add all available bookmarks
for (key in data) {
for (key of data) {
$("<option value='" + key + "''>" + key + "</option>").appendTo("#connection_bookmarks");
}
$(".bookmarks").show();
}
else {
if (appFeatures.bookmarks_only) {
$("#connection_error").html("Running in <b>bookmarks-only</b> mode but <b>NO</b> bookmarks configured.").show();
$(".open-connection").hide();
} else {
$(".bookmarks").hide();
}
}
});
}
$("#connection_window").show();
function initConnectionWindow() {
if (appFeatures.bookmarks_only) {
$(".connection-group-switch").hide();
$(".connection-scheme-group").hide();
$(".connection-bookmarks-group").show();
$(".connection-standard-group").hide();
$(".connection-ssh-group").hide();
} else {
$(".connection-group-switch").show();
$(".connection-scheme-group").hide();
$(".connection-bookmarks-group").show();
$(".connection-standard-group").show();
$(".connection-ssh-group").hide();
}
}
function getConnectionString() {
@@ -1122,6 +1257,11 @@ function bindTableHeaderMenu() {
var menuItem = $(e.target);
switch(menuItem.data("action")) {
case "display_value":
var value = $(context).text();
$("#content_modal .content").text(value);
$("#content_modal").show();
break;
case "copy_value":
copyToClipboard($(context).text());
break;
@@ -1146,6 +1286,15 @@ function bindCurrentDatabaseMenu() {
var menuItem = $(e.target);
switch(menuItem.data("action")) {
case "show_db_stats":
showDatabaseStats();
break;
case "download_db_stats":
downloadDatabaseStats();
break;
case "server_settings":
showServerSettings();
break;
case "export":
openInNewWindow("api/export");
break;
@@ -1351,8 +1500,39 @@ function onInputResize(event) {
resizeInput(computedHeight);
}
function bindContentModalEvents() {
var contentModal = document.getElementById("content_modal");
$(window).on("click", function(e) {
// Automatically hide the modal on any click outside of the modal window
if (e.target && !contentModal.contains(e.target)) {
$("#content_modal").hide();
}
});
$("#content_modal .content-modal-action").on("click", function() {
switch ($(this).data("action")) {
case "copy":
copyToClipboard($("#content_modal pre").text());
break;
case "close":
$("#content_modal").hide();
break;
}
});
$("#results").on("dblclick", "td > div", function() {
var value = unescapeHtml($(this).html());
if (!value) return;
$("#content_modal pre").html(value);
$("#content_modal").show();
})
}
$(document).ready(function() {
bindInputResizeEvents();
bindContentModalEvents();
$("#table_content").on("click", function() { showTableContent(); });
$("#table_structure").on("click", function() { showTableStructure(); });
@@ -1624,55 +1804,31 @@ $(document).ready(function() {
});
$("#connection_bookmarks").on("change", function(e) {
var name = $.trim($(this).val());
if (name == "") return;
var selection = $(this).val();
var item = bookmarks[name];
var inputs = [
$("#connection_form input[type='text']"),
$("#connection_form input[type='password']"),
$("#connection_ssl")
];
// Check if bookmark only has url set
if (item.url && item.url != "") {
$("#connection_url").val(item.url);
$("#connection_scheme").click();
return;
}
// Fill in bookmarked connection settings
$("#pg_host").val(item.host);
$("#pg_port").val(item.port);
$("#pg_user").val(item.user);
$("#pg_password").val(item.password);
$("#pg_db").val(item.database);
$("#connection_ssl").val(item.ssl);
if (item.ssh && Object.keys(item.ssh).length > 0) {
$("#ssh_host").val(item.ssh.host);
$("#ssh_port").val(item.ssh.port);
$("#ssh_user").val(item.ssh.user);
$("#ssh_password").val(item.ssh.password);
$("#ssh_key").val(item.ssh.key);
$("#ssh_key_password").val(item.ssh.keypassword);
$("#connection_ssh").click();
}
else {
$("#ssh_host").val("");
$("#ssh_port").val("");
$("#ssh_user").val("");
$("#ssh_password").val("");
$("#ssh_key").val("");
$("#ssh_key_password").val("");
$(".connection-ssh-group").hide();
$("#connection_standard").click();
}
inputs.forEach(function(selector) {
selector.val("").prop("disabled", selection == "" ? "" : "disabled");
});
});
$("#connection_form").on("submit", function(e) {
e.preventDefault();
var button = $(this).find("button.open-connection");
var params = {
url: getConnectionString()
};
var params = {};
var bookmarkID = $.trim($("#connection_bookmarks").val());
if (bookmarkID != "") {
params["bookmark_id"] = $("#connection_bookmarks").val();
}
else {
params.url = getConnectionString();
if (params.url.length == 0) {
return;
}
@@ -1686,6 +1842,7 @@ $(document).ready(function() {
params["ssh_key"] = $("#ssh_key").val();
params["ssh_key_password"] = $("#ssh_key_password").val()
}
}
$("#connection_error").hide();
button.prop("disabled", true).text("Please wait...");
@@ -1700,6 +1857,7 @@ $(document).ready(function() {
else {
connected = true;
loadSchemas();
loadLocalQueries();
$("#connection_window").hide();
$("#current_database").text(resp.current_database);
@@ -1740,6 +1898,7 @@ $(document).ready(function() {
connected = true;
loadSchemas();
loadLocalQueries();
$("#current_database").text(resp.current_database);
$("#main").show();