Compare commits

...
670 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
Dan Sosedoff 81b7c1c6aa Version bump: 0.12.0 2022-12-13 11:59:45 -06:00
Dan SosedoffandGitHub 0dfec506cf Improved Pg test matrix (#616)
* Add postgres 10/11 to test  matrix
* Add docker-compose file fo running multiple postgres versions locally
* Fix client test for pg 10, modify function details to include specific fields
* Try to install latest postgres client
* Add concurrency setting
2022-12-12 18:58:00 -06:00
Dan SosedoffandGitHub 4c40eef99a Perform client version validation before executing pg_dump command (#614)
* Add func to parse out pg_dump version
* Perform client vs server version checking before dump exports
* Fix dump tests
* Add extra test to validate against empty server version
* Fix attachment filenames cleanup function
* Add extra test
* Fix small typos in comments
* Drop third-party package to deal with versions
* Tweak the pg dump incompatibility error message
* Run CI on pull requests
2022-12-12 15:09:12 -06:00
Dan SosedoffandGitHub 7557ac854e Configure client side query timeout (#610)
* Configure client side query timeout
* Update test command on windows
* Make query timeout cli opt an uint
* Fix windows test command
* Check for updates when rendering connection page
* Fix typo
2022-12-08 15:07:40 -06:00
Dan SosedoffandGitHub 0008842a68 Add test for server type and version detection (#612) 2022-12-08 13:33:38 -06:00
Dan Sosedoff 1754274d46 Fix ui flake when switching to activity and connection tabs 2022-12-08 12:43:42 -06:00
Dan SosedoffandGitHub dc2b8f7c5e Switch windows tests from Appveyor to Github Actions (#611)
* Run unit tests on win in gha
* Decomissions appveyor 🫡
2022-12-07 22:17:19 -06:00
Dan SosedoffandGitHub d08dbf34aa Implement global query timeout option (#609)
* Add global query timeout
* Tweak option settings
* Add timeout test
* Move query timeout option close to idle timeout
2022-12-07 18:56:58 -06:00
Dan Sosedoff adf1e4e9ea Change go version in the module 2022-12-07 17:15:46 -06:00
Dan Sosedoff 138fd8af09 Remove stray console.log 2022-12-07 12:04:33 -06:00
Dan SosedoffandGitHub 38051b9465 Add support for user functions (#608)
* Add initial support for functions
* Show functions definitions
* Fix client tests
* Fix schema objects search
* Perform partial matching for functions
* Add function test
* Make sure to close client connections so that database could be dropped in tests
* Fix lint
* Allow to copy the view/functions definitions
* Nits
2022-12-07 11:58:07 -06:00
Dan SosedoffandGitHub bbe9a97d05 Switch go build target to 1.19 (#603)
* Switch go build target to 1.19

* Add make docker-run target for testing new images

* Bump appveyor go version

* Fix appveyor paths
2022-12-06 18:34:08 -06:00
Dan Sosedoff 2c745fa68d Use runtime-provided os/arch info 2022-12-06 18:14:47 -06:00
Dan Sosedoff 88557bfaf5 Fix awk param reference in BUILD_ARCH flag 2022-12-06 18:02:20 -06:00
Dan Sosedoff 829658ae4d Include build arch info into binary 2022-12-06 17:55:27 -06:00
Dan SosedoffandGitHub f48cc5f007 Use go embed to load queries from static files (#607) 2022-12-06 17:41:46 -06:00
Dan SosedoffandGitHub f4b3091666 Merge pull request #605 from sosedoff/log-level
Configure logging level and format
2022-12-06 12:41:56 -06:00
Dan Sosedoff 66fbc730fe Only include form for non-get requests 2022-12-06 12:30:43 -06:00
Dan Sosedoff 2f9d737515 Move request parameters logging into api request logger 2022-12-06 12:24:21 -06:00
Dan Sosedoff dda70352c0 Dont include request path in the log twice 2022-12-06 12:14:55 -06:00
Dan Sosedoff dbd3b26f6c Configure logging level and format 2022-12-06 12:09:21 -06:00
Dan SosedoffandGitHub e6f8153b34 Merge pull request #604 from sosedoff/version-print-cleanup
Add VersionString func to print out full app version
2022-12-06 11:51:42 -06:00
Dan Sosedoff 97e41fbfe5 Add VersionString func to print out full app version 2022-12-06 11:27:47 -06:00
Dan Sosedoff 3e1a93296e Pass ldflags for make install 2022-12-06 11:09:13 -06:00
Dan SosedoffandGitHub e188dd5850 Merge pull request #602 from sosedoff/json-time-marshalling-fix
Fix panic with time marshaling
2022-12-05 21:52:37 -06:00
Dan Sosedoff be81fdab41 Fix panic with time marshalling 2022-12-05 21:47:39 -06:00
Dan SosedoffandGitHub 5228eca9ae Merge pull request #601 from sosedoff/update-delete-with-returning-values
Handle returning values in update/delete queries
2022-12-05 21:12:46 -06:00
Dan Sosedoff e3d69d3cfa Fix lint 2022-12-05 21:01:08 -06:00
Dan Sosedoff 450badfbbb Handle returning values in update/delete queries 2022-12-05 20:56:21 -06:00
Dan SosedoffandGitHub 1cd79005c3 Merge pull request #600 from sosedoff/fly-ci-cd
Setup fly deployment
2022-12-05 18:00:23 -06:00
Dan Sosedoff 443208b217 Change name to demo 2022-12-05 18:00:03 -06:00
Dan Sosedoff 6b0abf1edc Swap the deploy branch 2022-12-05 17:58:26 -06:00
Dan Sosedoff 7ca17fdd2c Fix env var 2022-12-05 17:50:31 -06:00
Dan Sosedoff 098b24c276 Setup fly deployment 2022-12-05 17:49:49 -06:00
Dan SosedoffandGitHub 85b76960c3 Merge pull request #599 from sosedoff/resizable-query-height
Make query input box resizable
2022-12-05 17:14:51 -06:00
Dan Sosedoff 965f1f6eae Set min height of 100pix 2022-12-05 16:59:49 -06:00
Dan Sosedoff 9b086365d0 Minimize resize handlers code 2022-12-05 16:51:33 -06:00
Dan Sosedoff fbc228eb6c Make query input box resizable 2022-12-05 16:35:57 -06:00
Dan SosedoffandGitHub 97497b12f5 Merge pull request #597 from sosedoff/dev-assets-mode
Enable dev assets mode with PGWEB_ASSETS_DEVMODE env var
2022-12-03 20:47:54 -06:00
Dan Sosedoff 695a99dade Enable dev assets mode with PGWEB_ASSETS_DEVMODE env var 2022-12-03 18:27:46 -06:00
Dan SosedoffandGitHub be9a564874 Merge pull request #594 from sosedoff/view-definition-extras
Add ability to view and copy views/materialized views definitions
2022-12-03 17:01:44 -06:00
Dan Sosedoff e6c5075f7c Add ability to display view definition 2022-12-03 16:50:11 -06:00
Dan Sosedoff d3ff23ddfe Add context menu for materialized views 2022-12-03 16:50:11 -06:00
Dan Sosedoff bf6b2f8dda Add ability to copy view definition 2022-12-03 16:50:11 -06:00
Dan SosedoffandGitHub 3cec2c5c66 Merge pull request #596 from sosedoff/fix-flaky-backend-test
Fix flaky backend connection test
2022-12-03 16:49:51 -06:00
Dan SosedoffandGitHub 18f8f35672 Merge pull request #595 from sosedoff/indexes-list-include-sizes
Include index size on the index list view
2022-12-03 16:46:36 -06:00
Dan Sosedoff 1b42caf3a7 Fix flaky backend connection test 2022-12-03 16:42:30 -06:00
Dan Sosedoff 96f82201b5 Fix indexes test 2022-12-03 16:31:07 -06:00
Dan Sosedoff 606227d308 Include index size on the index list view 2022-12-03 16:16:00 -06:00
Dan Sosedoff 96625916e2 Tweak http message logger format 2022-12-03 15:11:45 -06:00
Dan Sosedoff a48f01992f Enable sessions on fly 2022-12-02 14:47:36 -06:00
Dan Sosedoff f1f4edf247 Update link to demo page 2022-12-02 14:45:07 -06:00
Dan SosedoffandGitHub e5480621ee Merge pull request #593 from sosedoff/sessions-manager
Add internal sessions manager
2022-12-02 14:35:43 -06:00
Dan Sosedoff 07d0010750 Make linter happy 2022-12-02 14:22:50 -06:00
Dan Sosedoff 16726e2461 Add idle timeout into session manager 2022-12-02 14:20:20 -06:00
Dan Sosedoff 0a133dc395 Reorder imports 2022-12-02 14:00:01 -06:00
Dan Sosedoff 73dfcc46c3 Verify client is closed 2022-12-02 13:59:22 -06:00
Dan Sosedoff bc54c95140 Tweak test run names 2022-12-02 13:57:56 -06:00
Dan Sosedoff 29a6a0df8c Add session manager tests 2022-12-02 13:56:49 -06:00
Dan Sosedoff 9074d4bfb8 Add internal sessions manager 2022-12-02 13:36:31 -06:00
Dan SosedoffandGitHub d8ee50ece6 Merge pull request #591 from sosedoff/connect-backend-logging
Configure logger for connect backend
2022-12-02 12:27:17 -06:00
Dan SosedoffandGitHub f493d0d04d Merge pull request #592 from sosedoff/default-build-flags
Set LDFLAGS for make build/release commands
2022-12-02 12:21:37 -06:00
Dan Sosedoff e7ac416440 Sanitize connect token in logging 2022-12-02 12:20:58 -06:00
Dan Sosedoff e65ced70ff Set LDFLAGS for make build/release commands 2022-12-02 11:56:18 -06:00
Dan Sosedoff f19f165afc Configure logger for connect backend 2022-12-02 11:45:23 -06:00
Dan Sosedoff f9e376a117 Code cleanup; add make lint task 2022-12-01 16:51:12 -06:00
Dan Sosedoff dafda4a977 Add VersionInfo struct to render in the /info endpoint 2022-12-01 16:41:46 -06:00
Dan Sosedoff 9b8cbb05e3 Include example backend implementation for the connect feature 2022-12-01 16:31:02 -06:00
Dan SosedoffandGitHub 6c3bd96606 Merge pull request #589 from sosedoff/request-logger
Implement gin request logger with param filtering
2022-12-01 13:14:48 -06:00
Dan Sosedoff 72ecd20dd1 Handle logger levels 2022-12-01 13:01:32 -06:00
Dan Sosedoff c996d71378 Implement gin request logger with param filtering 2022-12-01 13:01:32 -06:00
Dan SosedoffandGitHub 1eb86060b5 Merge pull request #590 from sosedoff/appveyor-go-1.18
Test with appveyor on go 1.18
2022-12-01 13:00:37 -06:00
Dan Sosedoff 18f2dcbd7d Test with appveyor on go 1.18 2022-12-01 12:55:41 -06:00
Dan SosedoffandGitHub 9639fa8db7 Merge pull request #588 from sosedoff/bugfix/587
Refactor data export js functionality
2022-12-01 12:11:46 -06:00
Dan Sosedoff c08d8cf617 Automatically add session ID to all generated URLs 2022-12-01 12:07:27 -06:00
Dan Sosedoff f6bd4e3a35 Rename exportURL function 2022-12-01 11:51:47 -06:00
Dan Sosedoff e13c6d0d1a Refactor code responsible for opening export pages in new window 2022-11-30 14:18:31 -06:00
Dan Sosedoff 2a4434a04a Generate a new url based on the old one using URL 2022-11-30 14:03:11 -06:00
Dan Sosedoff dd1fb90355 Refactor bookmarks ssl params 2022-11-23 16:21:30 -06:00
Dan SosedoffandGitHub 84bf1f091b Merge pull request #586 from sosedoff/cli-usage-env-vars
Print available environment variables in help message
2022-11-22 16:21:29 -06:00
Dan Sosedoff c103eed463 Comment fixes 2022-11-22 16:17:28 -06:00
Dan Sosedoff e1aa717b22 Print available environment variables in help message 2022-11-22 16:09:01 -06:00
Dan SosedoffandGitHub ee6898f243 Merge pull request #585 from sosedoff/cli-env-vars-support-with-prefix
Add support for PGWEB_ prefix environment variables
2022-11-22 15:25:07 -06:00
Dan Sosedoff a2a5415ace Print deprecation notice when using unprefixed env var 2022-11-22 15:20:49 -06:00
Dan Sosedoff b1023cdcfd Add support for PGWEB_ prefixed env vars 2022-11-22 14:51:13 -06:00
Dan Sosedoff fa0efb1597 Make getCurrentUser method private 2022-11-22 14:51:13 -06:00
Dan Sosedoff 0a7de05892 Update github.com/jessevdk/go-flags package 2022-11-22 14:51:13 -06:00
Dan SosedoffandGitHub e5281751c1 Merge pull request #584 from sosedoff/github-container-registry
Configure github container registry access for new releases
2022-11-15 17:54:11 -06:00
Dan Sosedoff c2fbfd2e6c Configure github container registry access for new releases 2022-11-14 17:13:41 -06:00
Dan Sosedoff c3ce8cfc3b Only run docker build workflow on master branch 2022-11-14 16:36:33 -06:00
Dan SosedoffandGitHub 3c6d98fa11 Merge pull request #582 from sosedoff/lint
Configure linter runs
2022-11-14 16:35:26 -06:00
Dan Sosedoff a5143ad9d3 Use checkout/setup v3 actions 2022-11-14 16:14:50 -06:00
Dan Sosedoff 9bfec11b48 Fix linting errors 2022-11-14 16:10:50 -06:00
Dan Sosedoff 580c1093d1 Add lint into github workflow 2022-11-14 15:49:30 -06:00
Dan Sosedoff c706523bd0 Lint: replace ioutil with os package 2022-11-14 15:47:36 -06:00
Dan Sosedoff 685c222b9d Lint: replace os.Kill trap with syscall.SIGTERM 2022-11-14 15:43:34 -06:00
Dan Sosedoff added3bb42 Add fly config for demo app 2022-11-14 11:28:32 -06:00
Dan SosedoffandGitHub 6fdf9261da Install latest postgres client (#577)
* Install latest postgres client

* Build on debian bullseye
2022-10-25 07:55:28 -04:00
Dan Sosedoff 13091c2a7c Add make install target 2022-09-18 10:14:51 -05:00
Joubert RedRatandGitHub 1f65fec9ea Add netcat install in dockerfile to provide a way to healthcheck in docker compose (#572) 2022-08-19 09:22:16 -05:00
Dan SosedoffandGitHub 9a75243732 Replace gox with a bash build script (#571) 2022-07-26 20:17:40 -05:00
Dan Sosedoff 2d6ec5dde7 Set build and release actions timeout to 30 mins 2022-07-23 18:46:07 -05:00
Dan Sosedoff fae23dc57c Update release action to use qemu and buildx v2 action 2022-07-23 18:26:19 -05:00
Dan Sosedoff 4607c1bb99 Update changelog 2022-07-05 21:13:08 -05:00
Dan Sosedoff 781469c4cf Version bump: 0.11.12 2022-07-05 21:11:03 -05:00
Dan SosedoffandGitHub ae3b74ad2a Docker build refactor (#568) 2022-06-27 22:31:57 -05:00
Dan SosedoffandGitHub 193073727a Use go 1.18 in github actions (#560) 2022-05-20 20:41:01 -05:00
Dan Sosedoff 9aa3a5a84e Do not run double checks in github actions 2022-05-20 20:39:51 -05:00
Boban AcimovicandGitHub 7b8b9f5c80 update go deps and alpine (#558) 2022-05-20 20:36:11 -05:00
Dan Sosedoff 28229c4fc7 Update changelog and docker image version 2022-03-29 23:35:22 -05:00
Dan Sosedoff db2a7a8aa5 Version bump: 0.11.11 2022-03-29 23:32:36 -05:00
Dan SosedoffandGitHub 89f84367ad Change binary codec back to none (#555) 2022-03-26 18:11:33 -05:00
Dan SosedoffandGitHub 4c096e8774 Auto-detect the query from the query source based on user selection (#547)
* Auto-detect the query from the query source based on user selection

* Misc comment
2022-02-10 20:14:42 -06:00
5803295174 Added binary codec base58 as well as improving the help for --binary-codec flag (#548)
* Added binary codec `base58` as well as improving the help for `--binary-codec` flag
* Added tests for base58
* Fixed tests

Co-authored-by: Matthieu Vachon <matt@streamingfast.io>
2022-01-27 22:10:01 -06:00
Dan Sosedoff ccf901d5c7 Bring back the pull_request github actions trigger 2022-01-24 18:32:15 -06:00
Dan Sosedoff 374dc600e0 Update changelog and docker target 2022-01-20 21:37:05 -06:00
Dan Sosedoff 8171907ec2 Version bump: 0.11.10 2022-01-20 21:28:05 -06:00
Dan Sosedoff 98f328a3fa Appveyor lint 2022-01-08 14:52:21 -06:00
Dan Sosedoff 3b4d0777a1 Bump license years 2022-01-08 14:52:05 -06:00
Dan SosedoffandGitHub e2f5e06c07 Add connect backend tests (#546) 2022-01-08 14:45:21 -06:00
Dan SosedoffandGitHub 0794c642e4 Use HTTP 302 status code for successful backend redirect (#544) 2022-01-08 12:25:24 -06:00
Dan Sosedoff 4c496feff2 Run github actions on pushes only 2022-01-04 17:17:30 -06:00
Dan SosedoffandGitHub 7718c249d0 Use go1.17 in go mod (#543) 2022-01-03 21:11:02 -06:00
Tim IngsandGitHub 17e95601ec Explain analyze dropdown button (#532)
* Adds bootstrap dropdown plugin
* Float dropdown toggle left
* Extract query running message toggles
* Adds analyze function and api call
* Adds analyze api
2022-01-03 19:05:46 -06:00
Dan SosedoffandGitHub af79994595 DRY ldflags; include build time into version string (#541) 2021-12-30 15:33:43 -06:00
Dan SosedoffandGitHub 706caa44bf Serialize binary bytea cols into hex/base64 (#537)
- Adds binary serialization into hex/base64
- Default codec is base64
- Codec can be changed via `--binary-codec` CLI option
2021-12-29 11:03:50 -06:00
Dan Sosedoff 1323012cff Replace travis link in readme 2021-12-25 11:45:10 -06:00
Dan SosedoffandGitHub ccfa3497f3 GitHub actions migration (#540) 2021-12-25 11:31:41 -06:00
Dan SosedoffandGitHub b1b50045a5 Merge pull request #536 from maoueh/feature/remove-alert-on-column-copy
Removes (annoying) `alert` on column copy value
2021-12-08 09:33:27 -06:00
Matthieu Vachon cc1666b16a Removes (annoying) alert on column copy value
All other copy to clipboard action are `alert` free like copying a table name to the clipboard. I think it should be the same for copying a column value.

If you think some feedback is necessary, maybe we should see a small label showing up than closing shortly after. As a comparison, it appears GitHub, in the commit list view, does not shot any feedback anymore (is it a bug maybe? because remote url copy still shows a feedback, anyway :)).

P.S. My editor automatically removed some trailing white spaces, if you prefer, I can revert those.
2021-12-07 23:06:28 -05:00
Dan Sosedoff be6ef48d40 Update changelog 2021-10-08 21:55:10 -05:00
Dan Sosedoff 7ec1e01edd Version bump: 0.11.9 2021-10-08 21:30:45 -05:00
Dan SosedoffandGitHub d44d1e77e8 Merge pull request #520 from tuaris/patch-1
FreeBSD rc.d startup script
2021-09-23 21:42:46 -04:00
Dan SosedoffandGitHub 206f6dd909 Merge pull request #527 from sosedoff/gin-upgrade
Upgrade gin to latest ; go mod tidy
2021-09-23 21:42:21 -04:00
Dan Sosedoff c109f25fae Upgrade gin to latest ; go mod tidy 2021-09-23 20:29:52 -05:00
Dan SosedoffandGitHub eab61f85b9 Merge pull request #522 from chenrui333/patch-2
chore: update image to use `alpine:3.14`
2021-08-17 20:51:25 -05:00
Dan SosedoffandGitHub 0252540c4b Merge pull request #525 from sosedoff/go-embed-path-prefix-fix
Fix broken path prefix
2021-08-17 20:47:18 -05:00
Dan Sosedoff 46935a7a12 Fix broken path prefix 2021-08-10 12:12:05 -05:00
Dan SosedoffandGitHub 360d7d87c5 Merge pull request #521 from chenrui333/patch-1
fix: should be build time rather than runtime
2021-07-22 08:25:49 -05:00
ruiandGitHub 01d4e9b9c6 chore: update image to use alpine:3.14 2021-07-09 15:54:08 -04:00
ruiandGitHub 218800395d fix: should be build time rather than runtime 2021-07-09 15:52:09 -04:00
Daniel MoranteandGitHub 46d84f4b2c FreeBSD rc.d startup script
Startup script for FreeBSD
2021-07-09 02:24:17 -04:00
Dan Sosedoff 3455a89fd5 Update version in dockerfile 2021-07-07 19:43:07 -05:00
Dan Sosedoff 49b6db19eb Update changelog 2021-07-07 19:19:18 -05:00
Dan Sosedoff 4de356cb1d Version bump: 0.11.8 2021-07-07 19:11:42 -05:00
Dan SosedoffandGitHub 9d1a3fd533 Merge pull request #516 from sosedoff/heroku-go-mod
Specify buildpack version for Heroku
2021-05-10 09:18:27 -05:00
Dan Sosedoff 8284c41d6e Specify buildpack version for Heroku 2021-05-10 09:00:09 -05:00
Dan SosedoffandGitHub cef4ab7eaa Merge pull request #513 from sosedoff/apple-silicon-binary-build
Add darwin/arm64 binary build target
2021-04-13 13:31:44 -05:00
Dan Sosedoff b5abd7555f Add darwin/arm64 binary build target 2021-04-12 09:59:15 -05:00
Dan Sosedoff ceece29737 Remove extra whitespace 2021-04-12 09:57:18 -05:00
Dan SosedoffandGitHub 4adba368c4 Merge pull request #510 from MobileTeleSystems/go-1-16-embed
go 1.16 features
2021-03-26 21:54:30 -05:00
Nikita Kazarian 8e2c826ba3 go 1.16 features
- use go embed instead of go-binary and remove all about old assets (go 1.16 feature)
- pin gox version (go 1.16 feature)
- update ci to go 1.16
2021-03-05 03:47:01 +03:00
Dan SosedoffandGitHub 68e6f2d117 Merge pull request #509 from MobileTeleSystems/move-to-go-modules
move project to go modules
2021-03-04 11:46:27 -06:00
Nikita Kazarian 4a3145bfbd add GO111MODULE=on to Makefile for go 1.12 support 2021-03-04 00:31:10 +03:00
Nikita Kazarian 21e8493838 move project to go modules
- remove vendor
- pin cockroach version in tests
- remove golang.org/x/tools/cmd/cover (not maintained and moved to standart library)
2021-03-04 00:15:23 +03:00
Dan Sosedoff a3f0de7916 Remove artifacts build workflow 2021-02-28 13:10:03 -06:00
Dan SosedoffandGitHub 64c5ca5cb1 Merge pull request #497 from camccar/Arm64Build
Add arm64 v7 option
2020-12-07 20:33:22 -06:00
caleb mccarthy 70069e4e23 Add arm64 v7 option 2020-12-05 15:26:57 -06:00
Dan Sosedoff 4066de695d Update changelog, docker version 2020-10-18 10:36:32 -05:00
Dan Sosedoff 31f6261c1c Version bump: 0.11.7 2020-10-18 10:29:42 -05:00
Dan SosedoffandGitHub e864b5f275 Merge pull request #494 from sosedoff/dump-test-error-adjust
Adjust error message matching for dump spec
2020-10-18 10:27:34 -05:00
Dan Sosedoff 86b1ea887c Adjust error message matching for dump spec 2020-10-18 10:18:03 -05:00
Dan Sosedoff 93d51c1feb Readme update 2020-10-18 10:14:04 -05:00
Dan SosedoffandGitHub ce6a777770 Merge pull request #493 from sosedoff/auth-error-regex-improvement
Use regular expression for checking some of the connection errors
2020-10-18 09:53:55 -05:00
Dan SosedoffandGitHub 440ae19c96 Merge pull request #492 from sosedoff/database-search-autocomplete-off
Disable autocomplete on database search field
2020-10-18 09:41:12 -05:00
Dan SosedoffandGitHub 7f66e3e523 Merge pull request #491 from sosedoff/ci-go-1.15
Run specs on go1.15
2020-10-18 09:35:54 -05:00
Dan Sosedoff f61c1c45d5 Use regular expression for checking some of the connectio errors 2020-10-18 09:32:34 -05:00
Dan Sosedoff 707fea6d13 Disable autocomplete on database search field 2020-10-18 09:22:53 -05:00
Dan Sosedoff ffd46c256c Run specs on go1.15 2020-10-18 09:21:29 -05:00
Dan SosedoffandGitHub e1fc9ddcef Merge pull request #489 from allisson/strip-binary
feat(release): strip binary to reduce size
2020-10-18 09:16:51 -05:00
Allisson Azevedo d3da40e0ad feat(release): strip binary to reduce size 2020-10-10 12:05:32 -03:00
Dan SosedoffandGitHub 3d14d42356 Merge pull request #479 from simhnna/patch-1
Do not show other databases if session is locked
2020-07-29 21:20:09 -05:00
Simon HannaandGitHub 27a83bdf9e Do not show other databases if session is locked
Fixes: #470
2020-07-15 18:11:05 +02:00
Dan SosedoffandGitHub a5f9df34fb Merge pull request #474 from jc00ke/master
Add note to README about connecting over Unix domain sockets
2020-06-09 07:40:53 -05:00
Jesse Cooke 92dbbbd5e8 Add note to README about connecting over Unix domain sockets 2020-06-07 16:59:06 -07:00
Dan SosedoffandGitHub 111b1720b4 Merge pull request #463 from sosedoff/skip-local-auth-fail
Do not terminate if local authentication failed on start
2020-04-27 10:15:25 -05:00
Dan Sosedoff 02ddf85af2 Do not terminate if local authentication failed on start 2020-04-23 15:54:12 -05:00
Dan SosedoffandGitHub 82b2918963 Merge pull request #459 from sosedoff/go1.14
Run CI on 1.14
2020-03-28 09:55:08 -05:00
Dan Sosedoff 368b60b7e6 Run CI on 1.14 2020-03-28 09:32:30 -05:00
Dan Sosedoff d9586c5200 Fix postgres runs in test-all script 2020-03-15 20:54:17 -05:00
Dan SosedoffandGitHub 136206fcff Merge pull request #457 from sosedoff/query-results-context-menu
Show results row context menu on custom query results
2020-03-15 20:22:09 -05:00
Dan Sosedoff 678a6fd7cb Show results row context menu on custom query results 2020-03-15 19:50:19 -05:00
Dan Sosedoff 16e3cc3998 Update docker version, changelog 2020-02-19 20:30:36 -06:00
Dan Sosedoff 3e4e9c30c9 Release 0.11.6 2020-02-19 20:24:33 -06:00
Dan Sosedoff 9aa52aa042 Remove contact section 2020-02-12 13:31:43 -06:00
Dan Sosedoff fb83d9635b License years update 2020-02-12 13:31:13 -06:00
Dan SosedoffandGitHub e112e21891 Merge pull request #455 from sosedoff/remote-double-click-on-cell
Remove double click action on cell
2020-02-12 13:28:37 -06:00
Dan Sosedoff 6025b7afb2 Remove double click action on cell
This feature was a source of confusion for new pgweb users for quite
some time now. Most people thought by double-clicking the cell it
would allow them to edit the value since the column is replaced
with textarea. The textarea was there to copy the cell's value, not
for editing. Feature is no longer required due to availability of a
cell context menu, which provides a link to copy the value.
2020-02-12 13:14:58 -06:00
Dan Sosedoff dc390b552d Build binaries for github.ref 2020-02-11 22:49:30 -06:00
Dan SosedoffandGitHub 9fabb602bf Testing github action workflow (#454)
Build and upload linux/mac binaries to S3 (unofficial releases)
2020-02-11 22:37:24 -06:00
Dan SosedoffandGitHub 12405d5106 Merge pull request #452 from sosedoff/ssl-root-cert
Extended SSL options
2020-02-11 11:11:07 -06:00
Dan Sosedoff e2217e5f71 Add tests for ssl params 2020-02-11 10:58:40 -06:00
Dan Sosedoff ce4aea8da1 Add ssl cert/key options 2020-02-05 22:09:13 -06:00
Dan Sosedoff 9c7f566d4d Add option to support sslrootcert parameter 2020-02-05 22:01:25 -06:00
Dan Sosedoff cc35a754e0 Add dockerignore 2019-12-16 17:26:23 -06:00
Dan Sosedoff c9a6065867 Update changelog, bump docker version 2019-12-16 17:23:34 -06:00
Dan Sosedoff 9d0a0e1db1 Release 0.11.5 2019-12-16 17:15:55 -06:00
Dan SosedoffandGitHub 61fc975ac9 Merge pull request #450 from sosedoff/include-go-version-into-info
Include go version into info response
2019-12-04 20:40:02 -06:00
Dan Sosedoff 43a2945208 Include go version into info response 2019-12-04 20:29:23 -06:00
Dan SosedoffandGitHub 0d8502f799 Merge pull request #449 from sosedoff/object-autocompletion
Autocomplete on all database objects
2019-12-04 19:51:40 -06:00
Dan Sosedoff 5d2ec70476 Autocomplete on all database objects 2019-12-04 19:36:09 -06:00
Dan SosedoffandGitHub e54030dee9 Merge pull request #448 from jim68000/master
Fix for visual glitch when schema name wider than 250px
2019-12-04 12:39:42 -06:00
Jim Smith e5c53d598c Fix for visual glitch when schema name wider than 250px 2019-12-04 18:28:14 +00:00
Dan SosedoffandGitHub 07a9b00710 Merge pull request #447 from sosedoff/go-runtime-in-version
Include go version into builds
2019-11-29 14:13:21 -06:00
Dan Sosedoff 8b1f234b91 Include go version into builds 2019-11-29 13:59:50 -06:00
Dan Sosedoff a21ff40588 Add .idea to gitignore 2019-11-16 11:52:15 -06:00
Dan Sosedoff d833df8b0d Update heroku go version to 1.13 2019-11-02 13:45:56 -05:00
Dan Sosedoff bc95e8dced Add extra styling for autocomplete 2019-11-02 13:37:52 -05:00
Dan SosedoffandGitHub 9afc152f25 Merge pull request #443 from oleggator/autocompletion
Add SQL keyword autocompletion
2019-11-02 13:36:49 -05:00
Dan SosedoffandGitHub d9ea14e37f Merge pull request #445 from sosedoff/ssh-key-handling
SSH key handling update
2019-11-02 13:21:49 -05:00
Dan Sosedoff 8428d268b1 Lint 2019-11-02 13:00:23 -05:00
Dan Sosedoff 7475f398b1 Add support for encrypted ssh keys 2019-11-02 12:44:04 -05:00
Dan Sosedoff 9e64bb5eec Update ssh dependency 2019-11-02 11:36:42 -05:00
Dan Sosedoff 994ceca1d1 Return error when ssh key does not exist 2019-11-02 11:36:31 -05:00
Dan Sosedoff f2ab51a314 Update changelog, bump docker image version 2019-10-05 12:54:19 -05:00
Dan Sosedoff 059764d015 Release 0.11.4 2019-10-05 12:48:21 -05:00
Oleg Utkin abf6acf82e add sql keyword autocompletion 2019-10-03 01:20:18 +03:00
Oleg Utkin 50a9a87a6a upgrade ace to 1.4.6 2019-10-03 01:17:29 +03:00
Dan SosedoffandGitHub 7a6450091a Code formatting and cleanup (#442)
* Formatting, extract error messages
* More refactor
* Move errors to a separate file
* Add missing file
* Misc
2019-09-29 12:16:42 -05:00
Dan SosedoffandGitHub c4db1973c4 Merge pull request #441 from sosedoff/drop-pg-dump-params
Drop unsupported pg_dump options from connection string
2019-09-25 20:39:55 -05:00
Dan Sosedoff b6f01da232 Drop unsupported pg_dump options from connection string 2019-09-25 20:29:21 -05:00
Dan Sosedoff d5e4a10137 Fix matrix 2019-09-21 12:49:30 -05:00
Dan Sosedoff 37c861cc81 Update travis matrix 2019-09-21 12:44:39 -05:00
Dan SosedoffandGitHub 1e4a09d8b5 Merge pull request #438 from sd65/fix-sql-export
Fix SQL export filename
2019-07-31 20:45:17 -05:00
Dan SosedoffandGitHub 27ace7cafd Merge pull request #439 from sd65/upgrade-alpine-to-3.10
Upgrade alpine to 3.10 - fix SQL export on up-to-date Postgresql
2019-07-31 10:21:58 -05:00
Sylvain DOIGNON 0bdf8b3498 display image size in docker-release task 2019-07-31 16:43:17 +02:00
Sylvain DOIGNON 0fc6ab8636 Upgrade alpine to 3.10 2019-07-31 15:32:51 +02:00
Sylvain DOIGNON 5bb339e1a2 fix SQL export filename 2019-07-31 14:43:55 +02:00
Dan Sosedoff 1334e48fc7 Update dockerfile 2019-07-24 17:28:15 -05:00
Dan Sosedoff 25af34aa6a Changelog update 2019-07-24 17:13:49 -05:00
Dan Sosedoff c830a55a64 Release 0.11.3 2019-07-24 17:07:27 -05:00
Dan SosedoffandGitHub 7365dbdb11 Merge pull request #435 from sosedoff/update-postgres-dep
Update lib/pg to 1.1.1
2019-07-05 17:42:07 -05:00
Dan Sosedoff f31143ba3a Set odd background color for table rows 2019-07-05 17:28:55 -05:00
Dan Sosedoff 5a50155cd3 Update lib/pg to 1.1.1 2019-07-05 16:53:01 -05:00
Dan Sosedoff 6843ca966b New changelog entries [ci-skip] 2019-07-05 16:31:30 -05:00
Dan SosedoffandGitHub 06a9d8015c Merge pull request #434 from sosedoff/restructure-sidebar
Sidebar restructure
2019-07-05 16:29:32 -05:00
Dan Sosedoff 5c3b21d711 Expland lists of objects on search 2019-07-05 16:20:46 -05:00
Dan Sosedoff 416cae3101 Add database object filter to the sidebar 2019-07-05 16:18:29 -05:00
Dan Sosedoff 819b13fe55 Restructure sidebar 2019-07-05 15:19:05 -05:00
Dan SosedoffandGitHub 15c21b6379 Merge pull request #421 from sosedoff/restrict-keywords
Keyword restriction in read-only mode
2019-02-25 19:18:52 -06:00
Dan SosedoffandGitHub 2b6d955764 Merge pull request #423 from sosedoff/homebrew-update-formula-script
Add script to bump Homebrew formula version
2019-02-25 18:14:23 -06:00
Dan Sosedoff 60337027dc Add script to bump Homebrew formula version 2019-02-25 12:31:46 -06:00
Dan Sosedoff 2a0fd54855 Remove comments from the query before checking for restricted keywords 2019-02-25 11:43:04 -06:00
Dan Sosedoff b83f52707a Remove LOCK/SET from restricted keywords 2019-02-20 20:29:28 -06:00
Dan Sosedoff 5bb6378e94 Switch back readonly mode after test finish 2019-02-20 18:22:44 -06:00
Dan Sosedoff 40eb74529e Reject queries that contain restricted keywords in read-only mode 2019-02-20 18:20:27 -06:00
Dan Sosedoff 9f29b10098 Update changelog/dockerfile 2019-02-15 14:16:34 -06:00
Dan Sosedoff d3aaf8f5db Release 0.11.2 2019-02-15 14:09:49 -06:00
Dan SosedoffandGitHub 26cd70cbaa Merge pull request #414 from sosedoff/fix-estimation-for-camelcase-schemas
Fix estimation for camelcase schemas
2019-02-08 14:25:19 -06:00
Dan Sosedoff 65aae5ec95 Add extra test cases for camelCase table names 2019-02-08 14:02:36 -06:00
Dan Sosedoff 5af4332593 Fix SQL query for camelCase tables row estimation 2019-02-08 13:27:40 -06:00
Dan Sosedoff 12717f15cc Changelog update 2019-01-28 17:18:55 -06:00
Dan Sosedoff dc1009cdd8 Release 0.11.1 2019-01-28 17:11:04 -06:00
Dan SosedoffandGitHub 52bda5f823 Merge pull request #409 from sosedoff/switch-bindata-dep
Switch to github.com/go-bindata/go-bindata fork
2019-01-28 16:51:43 -06:00
Dan Sosedoff 44b6932202 Regenerate bindata 2019-01-28 16:45:41 -06:00
Dan Sosedoff 0556d8b69d Switch to github.com/go-bindata/go-bindata fork 2019-01-28 16:32:43 -06:00
Dan SosedoffandGitHub 01516e030b Merge pull request #408 from sosedoff/fix-auto-user-detection
Fix error when automatic user detection fails
2019-01-28 14:30:57 -06:00
Dan Sosedoff c32d68809b Add extra note 2019-01-28 14:22:05 -06:00
Dan Sosedoff d48172986e Fix error when automatic user detection fails 2019-01-28 14:03:45 -06:00
Dan Sosedoff e6f6dbbddd Update license years 2019-01-28 12:08:25 -06:00
Dan Sosedoff 7995a588bd Remove apt cache when building docker container 2019-01-28 12:02:30 -06:00
Dan SosedoffandGitHub baa587166c Merge pull request #405 from Nhat002/fix_encode_query_to_base64_in_export
Add custome base64 encode to handle special characters on query to export result
2019-01-28 11:47:36 -06:00
Dan SosedoffandGitHub 8dab66567d Merge pull request #406 from sosedoff/fix-startup-error-with-db-url
Fix startup error when DATABASE_URL is set
2019-01-25 11:08:02 -06:00
Dan Sosedoff a7959ad0fa Fix startup error when DATABASE_URL is set 2019-01-25 10:29:24 -06:00
Nhat Tran 25304f8229 Fix: missing base64.js in index.html and remove blank space 2019-01-25 12:04:21 +08:00
Nhat Tran bc0602c523 Add custome base64 encode to handle special character 2019-01-24 15:35:16 +08:00
Dan Sosedoff ae60604ef1 Fix typo 2019-01-16 22:54:23 -06:00
Dan Sosedoff 4bdcfbd5b1 Update changelog/dockerfile 2018-12-24 13:08:20 -06:00
Dan Sosedoff 53c02d8914 Release 0.11.0 2018-12-24 12:57:39 -06:00
Dan SosedoffandGitHub 4d74052ad8 Merge pull request #401 from sosedoff/db-object-counter-style
Tweak sidebar database object counters styles
2018-12-19 19:41:45 -06:00
Dan Sosedoff 435a67cb5d Tweak sidebar database object counters styles 2018-12-19 12:40:28 -06:00
Dan SosedoffandGitHub aadee6ac8a Merge pull request #399 from sosedoff/fix-start-with-default-db
Do not exit with error if local server is not running
2018-12-18 20:23:09 -06:00
Dan Sosedoff 9c07ac356b Do not exit with error if local server is not running 2018-12-18 18:11:56 -06:00
Dan SosedoffandGitHub 9a3c79d93d Merge pull request #398 from sosedoff/ssh-host-callback-fix
SSH connection fix
2018-12-14 20:18:41 -06:00
Dan Sosedoff 2bd9f39da5 Fix SSH error: ssh: must specify HostKeyCallback 2018-12-14 19:56:33 -06:00
Dan Sosedoff c4e63dcc85 Regenerate bindata 2018-12-13 22:36:38 -06:00
Dan Sosedoff 794e5a22c1 Rename command.VERSION to command.Version 2018-12-13 22:35:43 -06:00
Dan Sosedoff eeb5245cb9 Typo fix 2018-12-13 17:56:57 -06:00
Dan SosedoffandGitHub e8328b25bc Merge pull request #397 from sosedoff/restrict-activity
Scope activity list to current database
2018-12-13 13:12:49 -06:00
Dan Sosedoff 9c1ce91de1 Scope activity list to current database 2018-12-13 12:14:10 -06:00
Dan Sosedoff 073bcea56f Add note about pgweb pro version 2018-12-12 21:08:34 -06:00
Dan SosedoffandGitHub 9e3872c980 Merge pull request #396 from sosedoff/show-pgweb-version
Show current release and check for updates
2018-12-12 20:52:20 -06:00
Dan Sosedoff 658d14314b Show pgweb version on homep age and check for newer releases 2018-12-12 20:35:07 -06:00
Dan SosedoffandGitHub 107a3ac6d4 Merge pull request #395 from sosedoff/fix-bookmark-switch
Force switch back to default connection settings view
2018-12-12 20:19:37 -06:00
Dan Sosedoff ad82841eb2 Force switch back to default connection settings view 2018-12-12 18:29:07 -06:00
Dan SosedoffandGitHub c9d94b54d7 Merge pull request #394 from sosedoff/fix-row-estimation
Fix row estimation bug
2018-12-12 14:33:06 -06:00
Dan SosedoffandGitHub d96ddf9b18 Merge pull request #393 from sosedoff/debug-failed-queries
Print out failed query SQL and args with --debug flag
2018-12-12 13:57:17 -06:00
Dan Sosedoff 7917c2ad35 Simplify TableRowsCount func logic 2018-12-12 13:56:08 -06:00
Dan Sosedoff 2db5ce544f Fix row estimation bug
The variable `table` is mutated from `schema.tablename` to `tablename`
before being passed to other functions, which try to parse the schema
from the name. This results in schema name being `public` because it's
missing from the given table name.
2018-12-12 13:33:55 -06:00
Dan Sosedoff 474b99c6d5 Print out failed query SQL and args with --debug flag 2018-12-12 13:22:36 -06:00
Dan SosedoffandGitHub aded63f76f Merge pull request #390 from sosedoff/refactor2
Cleanup and refactor
2018-12-04 12:54:17 -06:00
Dan Sosedoff d1a7e6ecfe More lint 2018-12-04 12:42:37 -06:00
Dan Sosedoff a5b8e02a98 Cleanup backend handler 2018-12-01 22:35:11 -06:00
Dan Sosedoff 72af00f1b7 Refactor api package 2018-11-30 21:40:28 -06:00
Dan Sosedoff b86a849e00 Update pgweb version in dockerfile to 0.10.0 2018-11-28 10:39:02 -06:00
Dan Sosedoff 09c3a1862a Update changelog 2018-11-28 10:38:42 -06:00
Dan Sosedoff 54da27ef70 Release 0.10.0 2018-11-28 10:25:37 -06:00
Dan Sosedoff d9728cbb7a Changelog 2018-11-27 17:50:47 -06:00
Dan SosedoffandGitHub 420478ce6e Merge pull request #389 from sosedoff/fix-startup-with-default-db
Fix startup behavior when user did not provide a database name
2018-11-27 17:32:16 -06:00
Dan Sosedoff 21d2893292 Fix startup behavior when user did not provide a database name 2018-11-27 17:23:55 -06:00
Dan SosedoffandGitHub 1189bdef33 Merge pull request #388 from sosedoff/fix-json-naan-panic
Fix JSON marshal panic when dealing with NaN values
2018-11-27 17:00:38 -06:00
Dan SosedoffandGitHub 15d2e7917a Merge pull request #387 from sosedoff/url-prefix-env-var
Allow settings url prefix with URL_PREFIX env var
2018-11-27 17:00:10 -06:00
Dan Sosedoff 903a265676 Fix JSON marshal panic when dealing with NaN values 2018-11-27 16:51:51 -06:00
Dan Sosedoff 965cf754cf Allow settings url prefix with URL_PREFIX env var 2018-11-27 14:58:50 -06:00
Dan Sosedoff 3850eedf07 Add docker badge 2018-11-19 18:18:29 -06:00
Dan SosedoffandGitHub 0b400b1ffd Merge pull request #386 from HaraldNordgren/go_versions
Bump Go versions and use '.x' to always get latest patch versions
2018-11-14 18:40:21 -06:00
Dan Sosedoff abb143601b Add test for serverResult func 2018-11-05 17:07:32 -06:00
Harald Nordgren 4d193f0dd6 Bump Go versions and use '.x' to always get latest patch versions 2018-10-28 13:33:16 +01:00
Dan SosedoffandGitHub 9ca959c31c Merge pull request #381 from sosedoff/row-context-menu
Table row context menu
2018-09-16 16:42:51 -05:00
Dan Sosedoff 29d4b276e2 Do not show row context menu if results are empty 2018-09-14 18:19:24 -05:00
Dan Sosedoff 6ed3d5b501 Add result row context menu to copy value or filter rows 2018-09-14 18:14:18 -05:00
Dan Sosedoff 14037ddad2 Take out failing test due to new sessions behavior 2018-09-14 01:00:47 -05:00
Dan Sosedoff 1ecf18e41c Also flush bookmark under sessions/connect setup 2018-09-14 00:56:16 -05:00
Dan Sosedoff badcf700b9 Clear out connection settings when running in session/connect mode 2018-09-14 00:54:42 -05:00
Dan SosedoffandGitHub 527c680e1f Merge pull request #380 from sosedoff/localhost-default
Make localhost to be a default db host
2018-09-14 00:14:35 -05:00
Dan Sosedoff 174b2ffdbb Make localhost to be a default db host 2018-09-14 00:04:02 -05:00
Dan SosedoffandGitHub b4e2f2f13e Merge pull request #379 from sosedoff/numeric-stats-count
Include rows count to numeric stats view on table column
2018-09-14 00:00:11 -05:00
Dan Sosedoff fb7676ab4e Include rows count to numeric stats view on table column 2018-09-13 23:46:33 -05:00
Dan Sosedoff c9018e843e Regenerate bindata 2018-09-13 23:41:59 -05:00
Dan SosedoffandGitHub 31db9658e0 Merge pull request #377 from sosedoff/column-numstats
Add column context menu item to get numeric stats
2018-09-13 23:32:43 -05:00
Dan SosedoffandGitHub 449254bcfd Merge pull request #378 from sosedoff/connection-string-fixes
Fix issues with connection string builder
2018-09-13 23:31:06 -05:00
Dan Sosedoff fc380df8dd Fix failing win test 2018-09-13 23:22:25 -05:00
Dan Sosedoff dc4e8598f7 Refactor building connection string from options 2018-09-13 22:44:11 -05:00
Dan Sosedoff b0151ee985 Refactor connection string generator 2018-09-13 22:25:15 -05:00
Dan Sosedoff b9eba7bfa4 Add column context menu item to get numeric stats 2018-09-13 20:50:33 -05:00
Dan Sosedoff cad6c39e72 Update root package 2018-09-05 10:43:56 -05:00
Dan Sosedoff 2e64b56d14 Specify metadata for heroku 2018-09-05 10:35:45 -05:00
Dan SosedoffandGitHub 1620f2be56 Merge pull request #375 from sosedoff/dep
Dependencies update and switch to dep
2018-09-04 09:02:09 -05:00
Dan Sosedoff f6fa44b7fc Update go test script 2018-08-31 22:37:24 -05:00
Dan Sosedoff ab33472bf4 Turn off CGO for win 2018-08-31 22:30:59 -05:00
Dan Sosedoff 5ad15326ec Another appveyor pass 2018-08-31 22:24:36 -05:00
Dan Sosedoff 86c301bcf9 Try appveyor without overriding GOPATH 2018-08-31 22:19:48 -05:00
Dan Sosedoff 7a52df61ec Add missing dep install 2018-08-31 22:13:01 -05:00
Dan Sosedoff 0c40f1907e Update appveyor build script 2018-08-31 22:11:54 -05:00
Dan Sosedoff 35641e5957 Remove failing mime types from test 2018-08-31 22:01:51 -05:00
Dan Sosedoff 82130ecce0 Update CI matrix to use latest 2 versions of go 2018-08-31 21:57:48 -05:00
Dan Sosedoff ad81f666a5 Switch to dep for dependency management 2018-08-31 21:49:24 -05:00
Dan Sosedoff 847f47c5d3 Remove premium/donations section from readme 2018-08-20 21:18:49 -05:00
Dan Sosedoff aefcf81bb3 Changelog update 2018-08-20 21:15:50 -05:00
Dan Sosedoff e3b2d08021 Update dockerfile 2018-08-20 21:04:10 -05:00
Dan SosedoffandGitHub 3da2af9111 Merge pull request #370 from sosedoff/table-filter-tweaks
Automatically set table filter option to 'equals' if its not set
2018-08-20 20:54:16 -05:00
Dan Sosedoff a6eade1ae1 Automatically set table filter option to 'equals' if its not set 2018-08-20 20:42:38 -05:00
Dan SosedoffandGitHub 3b3d739e91 Merge pull request #356 from ericdagenais/itn/table-quotes
Fixes relation not found errors when dealing with table names that have uppercase characters
2018-06-26 22:23:48 -05:00
Eric Dagenais 9bb3afe6ce Merge remote-tracking branch 'upstream/master' into itn/tables-quotes-2
# Conflicts:
#	pkg/client/client.go
2018-06-20 07:19:34 -07:00
Dan SosedoffandGitHub 41a99be4ca Merge pull request #366 from allisson/master
Add EstimatedTableRowsCount to avoid count in large tables
2018-06-18 10:26:56 -05:00
Allisson Azevedo 808f6eea33 Change EstimatedTableRowsCount query 2018-06-16 18:12:11 -03:00
Allisson Azevedo 974570c79e Add more tests 2018-06-16 11:09:37 -03:00
Allisson Azevedo 5ba53d3cbd Add test_EstimatedTableRowsCount 2018-06-14 17:51:16 -03:00
Allisson Azevedo 8748f07167 Use EstimatedTableRowsCount only for postgresql 2018-06-14 16:36:22 -03:00
Allisson Azevedo d603b293d8 Add EstimatedTableRowsCount to avoid count in large tables 2018-06-14 16:10:59 -03:00
Dan SosedoffandGitHub 2ce5e9a1e2 Merge pull request #365 from sosedoff/cockroachdb
CockroachDB initial fixes
2018-06-05 22:59:02 -05:00
Dan Sosedoff 108754a8c3 Verify that database name is specified when opening the connection 2018-06-05 22:31:22 -05:00
Dan Sosedoff 8ea8960696 Fix killproc func in cockroach smoke test 2018-06-05 18:58:38 -05:00
Dan Sosedoff 1cbcb73948 Print a correct database server name when starting 2018-06-05 18:28:22 -05:00
Dan Sosedoff e68c7e2c61 Add cockroachdb smoke test 2018-06-05 18:20:58 -05:00
Dan Sosedoff e09f892442 Fix table info call, just return placeholder for now 2018-06-05 16:38:00 -05:00
Dan Sosedoff 56f3b45a4e Fix activity list 2018-06-05 15:48:16 -05:00
Dan Sosedoff 77770112bd Detect server type and version with regular expression 2018-06-05 15:35:19 -05:00
Dan SosedoffandGitHub b18af0b907 Merge pull request #358 from sosedoff/check-for-pgdump
Check if pg_dump is available before running database export
2018-04-26 00:14:17 -05:00
Dan Sosedoff da98261d27 Remove GOVENDOREXPERIMENT and add go 1.10.1 2018-04-26 00:08:30 -05:00
Dan Sosedoff 9fae7c06af Use -race flag with go test 2018-04-26 00:05:32 -05:00
Dan Sosedoff b29c013706 Use go get -u to fetch initial deps 2018-04-26 00:04:48 -05:00
Dan Sosedoff 0b095f43bb Restructure routes 2018-04-25 23:52:22 -05:00
Dan Sosedoff e2be689a05 Check if pg_dump is available before running database export 2018-04-25 23:29:18 -05:00
Dan Sosedoff 7dce0fb4f4 Cleanup whitespace in dockerfile 2018-04-25 23:19:39 -05:00
Dan SosedoffandGitHub a66cf92bd5 Merge pull request #357 from Bizzelicious/patch-1
Update Dockerfile
2018-04-25 23:19:00 -05:00
Jesper EnebergandGitHub 5c8bdf04a1 Update Dockerfile
* Changed MAINTAINER instruction to LABEL since MAINTAINER is deprecated. See https://docs.docker.com/engine/reference/builder/#maintainer-deprecated for more info.
2018-04-26 00:06:18 +02:00
Jesper EnebergandGitHub 315bf650d4 Update Dockerfile
* Removed `apk update`.
* Added `--no-cache` parameter. As of Alpine Linux 3.3 we can utilize the `--no-cache` option for apk. It allows us to install packages with an index that is updated and used on-the-fly and not cached locally. No need to run `apk update`, `apk add --update` and remove `/var/cache/apk/*` when done installing packages.
* Added postgres package so we can utilize pg_dump to dump tables and databases.
2018-04-26 00:00:05 +02:00
Dan Sosedoff f2579a7b37 Bump the pgweb version in docker file 2018-04-24 11:40:03 -05:00
Dan Sosedoff 319b88cedf Update changelog 2018-04-23 12:13:38 -05:00
Dan Sosedoff 9af721176b Release 0.9.12 2018-04-23 12:07:53 -05:00
Dan Sosedoff 113e2f27fb Regenerate bindata 2018-04-23 11:16:02 -05:00
Eric DagenaisandEric Dagenais 7b3e99b599 fixes issue that generates an error message when getting table info, getting table schema, and exporting data for tables with uppercase letters 2018-04-11 06:24:56 -07:00
Dan Sosedoff 2398035e53 Another pass 2018-03-16 20:15:06 -05:00
Dan Sosedoff d153d47604 Another pass at gofmt enforcement 2018-03-16 20:04:08 -05:00
Dan Sosedoff a02c813784 Check files with go fmt in CI 2018-03-16 19:59:58 -05:00
Dan SosedoffandGitHub d72f1c85c1 Merge pull request #347 from sosedoff/specs-refactor
Specs refactor and tweaks
2018-02-28 22:55:45 -06:00
Dan Sosedoff 62fc8a1054 Remove boot2docker mention, its deprecated and no longer supported 2018-02-28 22:35:02 -06:00
Dan Sosedoff e4b1c60ce5 Skip running tests on 10.x branch for now 2018-02-28 22:33:14 -06:00
Dan Sosedoff b620d8f0f2 Specify postgres 10.0 in test-all script 2018-02-22 14:41:45 -06:00
Dan Sosedoff 47500bf92e Specs refactor and tweaks
- Make ParseOptions func to take an arg and return struct so we can test it
- Refactor and add more tests for options parsing
- Run test suite on postgres 10.x branch
- Change format for connection idle timer from float64 to in
2018-02-22 14:20:18 -06:00
Dan Sosedoff 6938eb5f35 Update go versions for testing 2018-02-19 20:37:28 -06:00
Dan Sosedoff 8936adbe1c Merge branch 'go193' 2018-01-30 23:30:12 -06:00
Dan SosedoffandGitHub 121f278713 Merge pull request #343 from sosedoff/list-constraint-name
Include constraint name under "constraints" tab
2018-01-30 23:29:54 -06:00
Dan SosedoffandGitHub 0a0b07a255 Merge pull request #337 from ribice/patch-1
Update bookmarks.go
2018-01-30 23:22:33 -06:00
Dan Sosedoff f44b436bc1 Update travis config per recommendation 2018-01-30 23:20:19 -06:00
Dan Sosedoff 75ab487031 Go 1.9.3 on travis 2018-01-30 22:51:52 -06:00
Dan Sosedoff 133c936d7b License years 2018-01-30 22:42:37 -06:00
Dan Sosedoff b0146a5a76 Update test case to handle the constraint result rows 2018-01-30 22:41:12 -06:00
Dan Sosedoff 9c0d887a3c List name and definition for table constraints 2018-01-30 22:34:54 -06:00
Emir RibićandGitHub 2b2c76bde9 Update bookmarks.go
Fix typo
2018-01-08 10:55:05 +01:00
Dan Sosedoff 43fe84de3a Add link to PG docs about connection strings 2017-12-14 22:49:10 -06:00
Dan Sosedoff 318c33e8ff Changelog 2017-12-07 23:06:28 -06:00
Dan Sosedoff c32784e8ae Release 0.9.11 2017-12-07 22:57:56 -06:00
Dan Sosedoff 898cdd2d65 Bindata gen 2017-12-07 22:56:55 -06:00
Dan SosedoffandGitHub b441151e09 Merge pull request #330 from sosedoff/view-actions
Add views context menu to sidebar
2017-12-01 09:52:21 -06:00
Dan Sosedoff 0ea173ffe5 Add views context menu 2017-11-30 23:38:15 -06:00
Dan SosedoffandGitHub 4b1d0a614c Merge pull request #329 from sosedoff/db-prefix-for-export-files
Add db prefix for table export files
2017-11-30 22:57:44 -06:00
Dan Sosedoff 02d598f406 Add db prefix for table export files 2017-11-30 22:47:42 -06:00
Dan SosedoffandGitHub ae11e469d2 Merge pull request #327 from sosedoff/no-autocomplete-on-connection-url
Disable autocomplete on the connection url string for security reasons
2017-11-30 22:35:05 -06:00
Dan Sosedoff 4f2fac11d3 Disable autocomplete on the connection url string for security reasons 2017-11-30 22:25:20 -06:00
Dan Sosedoff 68bc907bd2 Godep: 1.9 2017-11-28 19:37:25 -06:00
Dan Sosedoff d7e5805024 Rename SQL query tab 2017-11-21 15:33:13 -06:00
Dan SosedoffandGitHub 97e31b6c47 Merge pull request #321 from sosedoff/cors
Add support for CORS
2017-11-18 21:49:07 -06:00
Dan SosedoffandGitHub cb67ef8258 Merge pull request #323 from sosedoff/table-context-reorg
Reorder table context menu
2017-11-16 23:18:39 -06:00
Dan Sosedoff dc3d7303d7 Reorder table context menu 2017-11-16 23:08:18 -06:00
Dan SosedoffandGitHub cdb74c6a19 Merge pull request #322 from sosedoff/fix-custom-results-rows
Hide custom query results counter if query didn't return any rows
2017-11-16 23:07:24 -06:00
Dan Sosedoff cf432e32f7 Hide custom query results counter if query didnt return any rows 2017-11-16 22:21:52 -06:00
Dan Sosedoff c3435e2ece Change CORS to Cors on CLI options, specify origins as an option 2017-11-16 22:10:14 -06:00
Dan Sosedoff b52394a166 Add support for CORS 2017-11-15 15:26:31 -06:00
Dan SosedoffandGitHub d65f8eb0f5 Merge pull request #320 from tylerlong/master
Fix sslmode typo
2017-11-11 10:58:59 -06:00
Tyler LongandGitHub 86acd842b0 Fix sslmode typo
https://www.postgresql.org/docs/9.1/static/libpq-ssl.html
2017-11-11 08:12:27 -06:00
Dan Sosedoff 69b89a0c2a Update docker image version 2017-11-03 22:05:46 -05:00
Dan Sosedoff 2992dd43e8 Update changelog 2017-11-03 22:05:29 -05:00
Dan Sosedoff cb4ecb49eb Release 0.9.10 2017-11-03 21:50:25 -05:00
Dan Sosedoff d51294ce54 Rebuild bindata 2017-11-03 21:49:32 -05:00
Dan SosedoffandGitHub 32eb35f7b2 Merge pull request #311 from krlevkirill/patch-1
Update README - add more badges
2017-10-30 20:52:10 -05:00
krlevkirillandGitHub 80717e6c69 Update README
add more badges
fix link to license
2017-10-29 20:07:28 +02:00
Dan SosedoffandGitHub d123f8fc07 Merge pull request #305 from sosedoff/assets-ci-guard
Trigger CI failure if bindata contains any dev links
2017-10-25 09:39:30 -05:00
Dan Sosedoff 2dcaf4a3a1 Remove bindata check from test-all script 2017-10-24 23:13:22 -05:00
Dan Sosedoff c4cdacc2d8 Add asset check script 2017-10-24 23:11:02 -05:00
Dan Sosedoff 06afa786ef Trigger CI failure if bindata contains any dev links 2017-10-24 22:38:51 -05:00
Dan Sosedoff b8fccab33a Fix broken bindata 2017-10-24 22:31:29 -05:00
Dan Sosedoff ec5a12b0f7 Rename disable connection timeout cli option 2017-10-20 22:18:07 -05:00
Dan SosedoffandGitHub 7b6c5f74d4 Merge pull request #303 from ldinc/issue#282
Issue#282
2017-10-20 22:14:26 -05:00
ldinc 02df9b519f #282: cleared bindata 2017-10-16 00:12:41 +03:00
ldinc 38a3f01868 #282: set flag idle logic to cli-run phase 2017-10-15 01:39:07 +03:00
ldinc a120b58dc2 #282: updated bindata 2017-10-14 14:37:20 +03:00
ldinc 33be6b036d Merge branch 'master' into issue#282 2017-10-14 14:34:32 +03:00
Dan Sosedoff 93211cf073 Merge branch 'EyesBear-master' 2017-10-13 23:04:52 -05:00
Dan Sosedoff 046022221e UI and design tweaks 2017-10-13 23:04:19 -05:00
Jason YeandDan Sosedoff fc01b1db81 Search for database with autocomplete 2017-10-13 22:59:24 -05:00
Dan Sosedoff ed7557c5da Regenerated bindata 2017-10-13 22:34:16 -05:00
Dan SosedoffandGitHub 5d110da392 Merge pull request #302 from ldinc/issue#301
added simple copy to clipboard from data-id prop
2017-10-13 22:33:58 -05:00
ldinc ab443f58d0 #301: change rexexp to String.split() 2017-10-14 03:20:42 +03:00
ldinc d4dde3fe4f #282 fix disabled state per idle timeout 2017-10-14 03:15:31 +03:00
ldinc 3067b06bf6 #282: added timeout flags & tuned client.IsIdle() 2017-10-14 03:08:04 +03:00
ldinc 0e2926cc85 added simple copy to clipboard from data-id prop 2017-10-14 01:23:53 +03:00
Dan SosedoffandGitHub 72b9eb443c Merge pull request #300 from sosedoff/disable-bookmark-on-startup
Dot now allow using startup bookmark in multi-session mode
2017-10-07 12:33:10 -05:00
Dan Sosedoff 4ad70d5392 Tweak wording 2017-10-07 11:30:12 -05:00
Dan Sosedoff d8ce8231cc Dot now allow using startup bookmark in multi-session mode 2017-10-07 09:25:00 -05:00
Dan SosedoffandGitHub 7381ff8589 Merge pull request #296 from sosedoff/ssh-hotfix
Fix panics when sshinfo is not set on bookmarks
2017-10-04 00:31:35 -05:00
Dan Sosedoff 277e2d6cd8 Add an extra test to verify SSHinfoIsEmpty does not panic 2017-10-03 23:03:02 -05:00
Dan Sosedoff 58ae69de0b Fix panics when sshinfo is not set on bookmarks 2017-10-03 23:00:18 -05:00
Dan Sosedoff f73f812cf1 Correction on idle timeout 2017-09-29 00:06:48 -05:00
Dan Sosedoff d1eba4a089 Update docker image version 2017-09-28 21:03:55 -05:00
Dan Sosedoff 502c13c3ce Fix formatting 2017-09-28 20:58:47 -05:00
Dan Sosedoff ee9878ac25 Update changelog 2017-09-28 20:56:24 -05:00
Dan Sosedoff 4cacfc253c Release 0.9.9 2017-09-28 20:42:14 -05:00
Dan SosedoffandGitHub b6167ce422 Merge pull request #279 from sosedoff/disable-ssh
Add new CLI option to disable SSH connections
2017-09-27 00:35:38 -05:00
Dan Sosedoff b1dd4b5308 Add new CLI option to disable SSH connections 2017-09-26 23:56:27 -05:00
Dan SosedoffandGitHub e7a7e02626 Merge pull request #277 from sosedoff/fix-ssh-connections
SSH Connection Fixes
2017-09-26 23:50:12 -05:00
Dan Sosedoff 5e22795828 Allow setting ssh connection key file, fix connection ui 2017-09-26 23:29:28 -05:00
Dan Sosedoff bf423658af Rename examples to config, it's a proper name 2017-09-26 22:12:01 -05:00
Dan SosedoffandGitHub 8bd6f08794 Merge pull request #275 from sosedoff/close-idle-sessions
Automatically close idle sessions
2017-09-22 23:01:01 -05:00
Dan Sosedoff 62d88121cd Automatically close idle sessions 2017-09-22 22:44:32 -05:00
Dan Sosedoff 85e38c390c Merge branch 'dump-test' 2017-09-22 22:17:29 -05:00
Dan Sosedoff 10b6e69dec Do not run CI on go 1.5/1.6 2017-09-22 22:08:32 -05:00
Dan Sosedoff 5d1cc211b0 Add database export test 2017-09-22 22:06:36 -05:00
Dan SosedoffandGitHub 83598f4013 Merge pull request #266 from sosedoff/connect-backend
Add ability to connect with settings from third-party backend
2017-09-21 20:37:46 -05:00
Dan SosedoffandGitHub 76eaa3bc4b Merge pull request #272 from sosedoff/auto-open-when-running
Automatically open pgweb page if its already running
2017-09-21 20:35:21 -05:00
Dan Sosedoff 92ec968d5e Automatically open pgweb page if its already running 2017-09-21 17:16:09 -05:00
Dan Sosedoff 049aa4ec44 Require --connect-backend flag to be set if any other backend flags are specified 2017-09-21 02:06:14 -05:00
Dan Sosedoff 869fd8c6bc Refactor the third-party connect backend functionality 2017-09-21 01:21:26 -05:00
Dan Sosedoff d175b0af34 Add descriptions for cli backend options 2017-09-19 00:45:07 -05:00
Dan Sosedoff 0aff72059b Check for response status when fetching credentials 2017-09-19 00:43:21 -05:00
Dan Sosedoff 168b33d5d5 Test on go1.9 2017-09-19 00:43:21 -05:00
Dan Sosedoff ccb84f585a Fix deps again 2017-09-19 00:43:21 -05:00
Dan Sosedoff 39e7b3f615 Update deps 2017-09-19 00:43:21 -05:00
Dan Sosedoff 73816ff2d7 Add ability to connect with settings from third-party backend 2017-09-19 00:43:21 -05:00
Dan SosedoffandGitHub f5f595ac02 Merge pull request #270 from sosedoff/data-export
Database export
2017-09-19 00:41:04 -05:00
Dan SosedoffandGitHub c0a4e7add1 Merge pull request #271 from sosedoff/pretty-json-default
Fix the JSON beautifier flag
2017-09-16 22:11:41 -05:00
Dan Sosedoff 26ea9fc1b8 Fix the JSON beautifier flag 2017-09-16 21:57:04 -05:00
Dan Sosedoff 3c515fcef3 Update readme: premium features/donations 2017-09-16 21:28:06 -05:00
Dan Sosedoff e771d6f49e Export database or table to sql 2017-09-16 20:32:41 -05:00
Dan SosedoffandGitHub 003a661c08 Merge pull request #268 from sosedoff/fetch-unique-rows
Table headers context menu
2017-09-16 14:22:10 -05:00
Dan Sosedoff 28b0840e69 Move some of the util functions to util.js 2017-09-16 14:03:42 -05:00
Dan Sosedoff 81af2a9a27 Add table results headers context menu 2017-09-16 13:50:22 -05:00
Dan SosedoffandGitHub 206416889c Merge pull request #265 from sosedoff/connection-usage
Record last query timestamp
2017-09-15 17:03:31 -05:00
Dan Sosedoff 6ab30ed308 Update the client last usage timestamp 2017-09-14 00:16:40 -05:00
Dan SosedoffandGitHub 3a9bb58646 Merge pull request #264 from sosedoff/print-version-on-start
Print postgres version on start in a single-session mode
2017-09-14 00:00:21 -05:00
Dan Sosedoff af7e504e4f Print postgres version on start in a single-session mode 2017-09-13 23:42:44 -05:00
Dan SosedoffandGitHub 2178a507c5 Merge pull request #255 from sosedoff/pretty-json-default
Pretty-print JSON results by default
2017-09-13 23:42:19 -05:00
Dan SosedoffandGitHub 7832b5b8b8 Merge pull request #256 from sosedoff/docker-alpine-3.6
Use alpine:3.6 as a docker base image
2017-09-13 23:36:33 -05:00
Dan Sosedoff d786d0018e Make JSON formatting a feature flag, enabled by default 2017-09-13 23:27:54 -05:00
Dan Sosedoff a9efb88fe2 Use alpine:3.6 as a docker base image 2017-08-09 23:11:30 -05:00
Dan Sosedoff e44e66a8e8 Use indent marshaller for JSON results by default 2017-08-04 22:10:12 -05:00
Dan Sosedoff 7d81998577 Update changelog and docker image 2017-08-04 21:58:43 -05:00
Dan Sosedoff 5e748d0cef Release 0.9.8 2017-08-04 21:44:22 -05:00
Dan Sosedoff 4f90561c41 Regenerate bindata 2017-08-04 21:41:15 -05:00
Dan SosedoffandGitHub dc57d5d15f Merge pull request #254 from skabbes/feature/comments
Expose postgres comments to table and columns
2017-08-04 19:40:04 -05:00
Steven Kabbes 2d03aa02e2 test: attempt to fix tests again 2017-08-04 14:16:59 -07:00
Steven Kabbes 8d4d564935 test: fix tests to expect comment column 2017-08-04 14:13:49 -07:00
Steven Kabbes e710dbf720 Add comments to tables and columns 2017-08-04 14:00:52 -07:00
Dan SosedoffandGitHub aba81334a5 Merge pull request #246 from sosedoff/confirm-disconnect
Require confirmation to close the connection
2017-06-05 23:15:31 -05:00
Dan SosedoffandGitHub c53c974713 Merge pull request #247 from sosedoff/postgres-9.6-ci
Test primarily on postgres 9.6
2017-06-05 23:15:17 -05:00
Dan Sosedoff 574f642200 Require confirmation to close the connection 2017-06-05 23:15:03 -05:00
Dan SosedoffandGitHub fd6e4cc189 Merge pull request #245 from sosedoff/for-of-fix
Javascript fixes for IE9-11
2017-06-05 22:46:03 -05:00
Dan Sosedoff c8b90d1286 Test primarily on postgres 9.6 2017-06-05 22:44:57 -05:00
Dan SosedoffandGitHub cbf50299ad Merge pull request #244 from sosedoff/bookmark-ssl-validation
Bookmarks fall back to sslmode=disable if mode is not set or invalid
2017-06-05 22:41:35 -05:00
Dan Sosedoff 26b2cdc194 Fix js on IE9-11 2017-06-05 22:29:44 -05:00
Dan Sosedoff 00ee7084a6 Specify how to run all tests 2017-06-05 21:30:53 -05:00
Dan Sosedoff 75dc1c1548 Fall back to sslmode=disable if ssl mode is not set or invalid in bookmarks 2017-06-05 21:29:09 -05:00
Dan Sosedoff 943963f760 Fix test for mime x-font-woff, pin down golang versions in travis 2017-06-05 21:08:56 -05:00
Dan SosedoffandGitHub 03d3c35a86 Merge pull request #240 from sosedoff/paginate-remember-sort-order
Remember sort column and order for pagination
2017-05-16 22:52:02 -05:00
Dan Sosedoff 3ed194b7bc Remember sort column and order for pagination 2017-05-16 22:34:58 -05:00
Dan Sosedoff c764d53b88 Regenerate bindata 2017-05-16 21:28:24 -05:00
Dan Sosedoff ccc5eddf92 Move all CLI logic into its own package 2017-05-16 21:28:07 -05:00
Dan SosedoffandGitHub 1ff2f7d7ba Merge pull request #238 from bjeanes/patch-1
Show query waiting for 9.6 in activity section
2017-05-10 22:40:49 -05:00
Bo JeanesandGitHub c3f518e5b7 Show query waiting for 9.6 in activity section 2017-05-11 13:30:16 +10:00
Dan SosedoffandGitHub 84cfef8480 Merge pull request #237 from sosedoff/activity-fix
Activity tab fix
2017-05-10 22:14:21 -05:00
Dan Sosedoff bf14066aba Alias procid column to pid so that frontend can properly read it 2017-05-09 23:01:10 -05:00
Dan Sosedoff c592d0063d Set activity queries for different pg versions 2017-05-09 22:51:26 -05:00
Dan Sosedoff e9787440fb Set server version for the client on start up 2017-05-09 21:53:24 -05:00
Dan Sosedoff 1ee02d1451 Add activity query client test 2017-05-09 21:53:24 -05:00
Dan Sosedoff 2378ba894d Reset results table on manual disconnect 2017-05-08 21:37:24 -05:00
Dan SosedoffandGitHub 0fd652f716 Merge pull request #234 from tamird/cockroach
api: properly check for errors
2017-04-30 11:25:38 -05:00
Tamir Duberstein c4a33206ce api: properly check for errors
This results in NPE when cl.Info() returns an error.
2017-04-29 16:56:31 -04:00
Dan SosedoffandGitHub 6289e479a5 Merge pull request #232 from alanfran/patch-1
Removed duplicate usage line in Makefile.
2017-04-27 08:30:15 -05:00
Antonio LanfranchiandGitHub 7d97f6424b Removed duplicate usage line in Makefile.
There were two lines for `make test`.
2017-04-27 05:16:10 -04:00
Dan Sosedoff 383d4049e0 Build docker image on 0.9.7 2017-04-10 10:17:03 -05:00
Dan Sosedoff 5fc844b840 Update changelog for 0.9.7 2017-04-04 22:16:39 -05:00
Dan Sosedoff 4833a76b05 Version bump: 0.9.7 2017-04-04 22:10:45 -05:00
Dan Sosedoff f594dd77fd Update bindata 2017-04-04 22:10:26 -05:00
Dan Sosedoff f99afad7f6 Show build status on linux based off master 2017-04-04 22:06:00 -05:00
Dan Sosedoff 1c327ac252 Use postgres 9.5 by default on travis 2017-02-27 11:52:18 -06:00
Dan Sosedoff 7556726e08 Test on Go 1.8 2017-02-26 15:55:45 -06:00
Dan SosedoffandGitHub 92729f26df Merge pull request #225 from sosedoff/reset-query-rows
Remove table rows when switching to custom query view
2017-02-25 14:43:07 -06:00
Dan Sosedoff 701e536a90 Remove table rows when switching to custom query view 2017-02-25 14:24:57 -06:00
Dan SosedoffandGitHub 1f45a95710 Merge pull request #224 from sosedoff/result-rows-count
Show number of rows rendered for custom sql query
2017-02-25 14:16:25 -06:00
Dan Sosedoff 9f7f83cfd2 Add example initd config 2017-02-25 14:15:03 -06:00
Dan Sosedoff 36fd4871e1 Show number of rows rendered for custom sql query 2017-02-25 14:04:48 -06:00
Dan Sosedoff 7882abea35 Update godeps 2017-02-03 20:29:16 -06:00
Dan SosedoffandGitHub 67fbadabf2 Merge pull request #217 from sosedoff/deps-update
Deps update
2017-01-24 11:16:55 -06:00
Dan SosedoffandGitHub a4f2553fbc Merge pull request #218 from kpashka/master
Ability to override bookmarks directory + typo fix
2017-01-23 19:25:34 -06:00
Pavel Kiselev 4704a8a234 Override in tests 2017-01-24 08:06:12 +07:00
Pavel Kiselev ee547c5899 Override parameter in main.go 2017-01-24 08:04:13 +07:00
Pavel Kiselev 9b1d915dd1 Ability to override bookmarks directory 2017-01-24 07:55:18 +07:00
Dan SosedoffandGitHub f6f40ab707 Merge pull request #214 from jeasoft/patch-1
Example Configuration File for Ubuntu 14.04
2017-01-20 14:15:49 -06:00
Dan Sosedoff 33f3e20849 Use port 30000 to test port availability so it does not interfere with running pgweb instance 2017-01-20 13:41:24 -06:00
Dan Sosedoff 4fcb946f0f Update dependencies 2017-01-20 13:21:18 -06:00
Dan Sosedoff 12976024b7 Update go version in godeps to 1.7 2017-01-20 13:13:00 -06:00
Dan Sosedoff 1f7367edb1 Update license years 2017-01-20 13:05:18 -06:00
Jearel AlcantaraandGitHub 0493d77f46 Example Configuration File for Ubuntu 14.04
Example Configuration File for Ubuntu 14.04 and Upstart, normally in:
/etc/init/
2017-01-12 20:55:50 -04:00
Dan Sosedoff 4af979ac9b Fix issue with long database names in header 2017-01-10 13:44:42 -06:00
Dan SosedoffandGitHub c28695fa18 Merge pull request #212 from fduraffourg/SwitchDb
Fix path rewrite on DB change
2017-01-10 10:49:00 -06:00
Florian Duraffourg 5ffd6d612b Fix path rewrite on DB change 2017-01-10 16:14:50 +01:00
Dan SosedoffandGitHub 577edd86f4 Merge pull request #206 from sosedoff/fix-lock-session
Do not return error when session is locked and database url is blank
2016-11-20 16:55:13 -06:00
Dan Sosedoff 412089948c Do not return error when session is locked and database url is blank 2016-11-20 16:44:33 -06:00
Dan Sosedoff 65773364a1 Update docker version 2016-11-18 12:51:37 -06:00
Dan Sosedoff 91bc5d0d5e Update changelog 2016-11-18 12:40:31 -06:00
292 changed files with 8882 additions and 41984 deletions
+3
View File
@@ -0,0 +1,3 @@
.github
bin/
./pgweb
+113
View File
@@ -0,0 +1,113 @@
name: checks
on:
push:
branches:
- main
paths-ignore:
- '**.md'
pull_request:
types:
- opened
- synchronize
paths-ignore:
- '**.md'
env:
GO_VERSION: "1.25"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
tests:
name: tests
runs-on: ubuntu-latest
timeout-minutes: 40
strategy:
matrix:
pg_version: [9.6, 10, 11, 12, 13, 14, 15, 16, 17, 18]
services:
postgres:
image: postgres:${{ matrix.pg_version }}
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: booktown
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Install latest Postgres client
run: |
sudo rm -f /etc/apt/sources.list.d/pgdg.list
curl --silent https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add
echo "deb http://apt.postgresql.org/pub/repos/apt/ `lsb_release -cs`-pgdg main" | sudo tee /etc/apt/sources.list.d/pgdg.list
sudo apt-get update && sudo apt-get install -y postgresql-client-18
- uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: actions/setup-go@v3
with:
go-version: ${{ env.GO_VERSION }}
- run: go mod download
- run: make test
env:
MallocNanoZone: 0 # https://github.com/golang/go/issues/49138
PGHOST: localhost
PGUSER: postgres
PGPASSWORD: postgres
PGDATABASE: booktown
tests-windows:
runs-on: windows-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: actions/setup-go@v3
with:
go-version: ${{ env.GO_VERSION }}
- run: go mod download
- run: make test
lint:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/setup-go@v3
with:
go-version: ${{ env.GO_VERSION }}
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: golangci-lint
uses: golangci/golangci-lint-action@v8
with:
version: v2.7.2
fmt:
name: fmt
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: actions/setup-go@v3
with:
go-version: ${{ env.GO_VERSION }}
- run: go mod download
- run: script/check_formatting.sh
+20
View File
@@ -0,0 +1,20 @@
name: demo deploy
on:
push:
branches:
- main
env:
FLY_API_TOKEN: ${{ secrets.FLY_TOKEN }}
jobs:
deploy:
name: Deploy to Fly
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v3
- uses: superfly/flyctl-actions/setup-flyctl@master
- run: flyctl deploy --remote-only
+37
View File
@@ -0,0 +1,37 @@
name: docker
on:
push:
branches:
- main
env:
GO_VERSION: "1.25"
CGO_ENABLED: 0
IMAGE_REPOSITORY: sosedoff/pgweb
jobs:
docker-build:
name: docker images
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Configure docker build context
uses: docker/setup-buildx-action@v2
- name: Build docker images
uses: docker/build-push-action@v2
with:
context: .
push: false
tags: pgweb:latest
platforms: linux/amd64,linux/arm64,linux/arm/v7
build-args: |
"CGO_ENABLED=${{ env.CGO_ENABLED }}"
+60
View File
@@ -0,0 +1,60 @@
name: release
on:
push:
tags:
- "v*"
env:
GO_VERSION: "1.25"
CGO_ENABLED: 0
DOCKER_REPOSITORY: sosedoff/pgweb
GHCR_REPOSITORY: sosedoff/pgweb
jobs:
docker-release:
name: Publish Docker images
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Configure docker build context
uses: docker/setup-buildx-action@v2
- name: Set reference tags
id: refs
run: |
echo ::set-output name=SOURCE_NAME::${GITHUB_REF#refs/*/}
echo ::set-output name=SOURCE_BRANCH::${GITHUB_REF#refs/heads/}
echo ::set-output name=SOURCE_TAG::${GITHUB_REF#refs/tags/v}
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to Github Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GH_TOKEN }}
- name: Build and push docker images
uses: docker/build-push-action@v2
with:
context: .
push: true
tags: |
${{ env.DOCKER_REPOSITORY }}:${{ steps.refs.outputs.SOURCE_TAG }}
${{ env.DOCKER_REPOSITORY }}:latest
ghcr.io/${{ env.GHCR_REPOSITORY }}:${{ steps.refs.outputs.SOURCE_TAG }}
ghcr.io/${{ env.GHCR_REPOSITORY }}:latest
platforms: linux/amd64,linux/arm64,linux/arm/v7
+3
View File
@@ -1,4 +1,7 @@
.DS_Store .DS_Store
.idea
.env
.envrc
pgweb pgweb
bin bin
tmp/ 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"]
-26
View File
@@ -1,26 +0,0 @@
sudo: required
language: go
services:
- docker
addons:
postgresql: "9.4"
go:
- 1.5.4
- 1.6.3
- 1.7
env:
- GO15VENDOREXPERIMENT=1
install:
- make setup
script:
- make build
- make test
- make test-all
- make docker-release
+295 -3
View File
@@ -1,4 +1,296 @@
## 0.9.6 - Unreleased ## Changelog
Current [release](https://github.com/sosedoff/pgweb/releases) is `0.17.0`.
## 0.17.0 - 2025-11-22
- `NEW` Update Dockerfile to use Golang 1.24 image, GH-821
- `NEW` Bump go crypto package to 0.44.x, GH-820, GH-823
- `NEW` Add PostgreSQL 18 support to Github Actions, GH-816
- `NEW` Connect backend refactor, GH-801
- `NEW` Add server settings view, GH-768
- `NEW` Add exec time of empty queries, GH-763
- `FIX` Minor typos, GH-764, GH-786
- `FIX` Shorten git revision printed in the --version output, GH-770
## 0.16.2 - 2024-11-02
- `FIX` Build a new Docker image with PostgreSQL 17 support
- `FIX` Run CI against PostgreSQL 17, GH-758
- `FIX` Rename master to main branch, GH-750
## 0.16.1 - 2024-09-07
- `FIX` Remove linux/arm/v5 from docker release action, GH-742
## 0.16.0 - 2024-06-04
- `NEW` Allow database stats downloads, GH-738
- `NEW` Add analyze table action, GH-737
- `NEW` Bump postgres version used in docker compose to 15, GH-729
- `NEW` Build on Go 1.22, GH-726
- `FIX` SSH tunnel cleanup and parse fixup, GH-731
- `FIX` Drop linux/arm/v5 from docker build, GH-728
- `FIX` Propagate CGO_ENABLED environment variable to docker build, GH-724
## 0.15.0 - 2024-03-14
- `NEW` Add support for a bookmarks-only mode, GH-716
- `FIX` Fix missing indexes by quoting schema/table name to ::regclass, GH-711
- `FIX` Continue on parseJSON error, GH-708
## 0.14.3 - 2024-01-28
- `NEW` Allow retrying a connection on startup, GH-695
- `NEW` Allow setting readonly mode in bookmarks, GH-707
- `FIX` Add UPDATE to list of restricted keywords in read-only mode, GH-697
## 0.14.2 - 2023-10-29
- `NEW` Execute tests using PostgreSQL 16, GH-691
- `FIX` Unclosed database sessions and tunnels, GH-688
- `FIX` Use pg_table_size for table stats query, GH-685
- `FIX` Use `HasSuffix` to correctly determine URL prefix, GH-684
## 0.14.1 - 2023-06-17
- `NEW` Add process start time metric, GH-675
- `NEW` Configure pgweb user for docker container, GH-674
- `NEW` Updated dockerfile, GH-645
- `FIX` Fix typo in the healthy metric, GH-657
- `NEW` Use entrypoint instead of cmd in in dockerfile, GH-654
## 0.14.0 - 2023-02-21
- `FIX` History page query loading fixup, GH-632
- `NEW` Display cell content via context menu, GH-634
- `NEW` Handle support/permissions errors in info call, GH-635
- `NEW` Show error message when API calls fail, GH-636
- `NEW` Add bookmark options to load username/password from env vars, GH-638
- `NEW` Add context menu to display database tables stats, GH-639
- `NEW` Added Local Queries feature, GH-641
- `FIX` Ensure that objects are sorted by schema and name, GH-648
- `FIX` Fetch local queries on db connect, GH-650
## 0.13.1 - 2022-12-27
- Fix connect flow when `~/.pgweb/bookmarks` directory is not available, GH-631
## 0.13.0 - 2022-12-25
- Add support for .pgpass file, GH-617
- Request logging additions (request id, forwarded user), GH-618
- Establish connections using bookmark ID only, GH-619
- Display empty schemas on the sidebar, GH-621
- Configure timeout and retries when testing connection status, GH-623
- Setup basic prom metrics endpoint, GH-624
- Add default connect_timeout option to connection string, GH-626
- Add duration_ms to log entries, GH-628
- Add query execution stats to api endpoint, GH-629
## 0.12.0 - 2022-12-13
- Deprecate usage of Gox for binary builds, GH-571
- Add netcat install in dockerfile to provide a way to healthcheck, GH-572
- Install latest postgres client in docker image, GH-577
- Add support for `PGWEB_` prefix environment variables, GH-585
- Fix export URL generation, refactor export code, GH-588
- Add logrus-based request logger, GH-589
- Configure logger for connect backend, GH-591
- Set LDFLAGS for make build/release commands, GH-592
- Add internal sessions manager, GH-593
- Include index size on the index list view, GH-595
- Fix flaky backend connection test, GH-596
- Add ability to view and copy views/materialized views definitions, GH-594
- Enable dev assets mode with PGWEB_ASSETS_DEVMODE env var, GH-597
- Make query input box resizable, GH-599
- Deprecate Heroku demo deployments and switch to Fly, GH-600
- Handle returning values in update/delete queries, GH-601
- Fix panic with invalid time marshaling, GH-602
- Configure logging level and format, GH-605
- Use go embed to load queries from static files, GH-607
- Switch go build target to 1.19, GH-603
- Add support for user functions, GH-608
- Implement global query timeout option, GH-609
- Switch windows tests from Appveyor to Github Actions, GH-611
- Fix activity endpoint panic when server version is not detected, GH-612
## 0.11.12 - 2022-07-05
- Update base docker image (alpine), update deps, GH-558
- Refactor docker images building, include ARM, GH-568
## 0.11.11 - 2022-03-29
- Auto-detect the query from the query source based on user selection, GH-547
- Added binary codec base58 as well as improving the help for --binary-codec flag, GH-548
- Change binary codec back to none, GH-555
## 0.11.10 - 2022-01-20
- Removes alert on column copy value, GH-536
- Migrate test suite to Github Action, GH-540
- Serialize binary bytea cols into hex/base64, GH-537
- Include build time into version string, GH-541
- Explain analyze dropdown button, GH-532
- Switch to go 1.17, GH-543
- Use HTTP 302 status code for successful backend redirect, GH-544
- Add connect backend tests, GH-546
## 0.11.9 - 2021-11-08
- Releases are built on Go 1.17
- Build time correction, GH-521
- Fix broken assets URL path prefix, GH-525
- Update docker build image to alpine:3.14, GH-522
- Upgrade gin dependency to v1.7.4, GH-527
- Add FreeBSD startup script, GH-520
## 0.11.8 - 2021-07-07
- Releases are built with Go 1.16
- Add ARM64 v7 build target, GH-497
- Switch to Go modules for dependency management, GH-509
- Switch to Go embed for static assets management, GH-510
- Add Darwin/ARM64 build target (Apple Silicon), GH-513
## 0.11.7 - 2020-10-18
- Releases are built with Go 1.15
- Show results row context menu on custom query results, GH-457
- Do not terminate if local authentication failed on start, GH-463
- Do not show other databases if session is locked, GH-470
- Strip debug information from binary to reduce size, GH-489
- Disable autocomplete on database search field, GH-492
- Improve windows connection error matching during start, GH-493
## 0.11.6 - 2020-02-19
- Add CLI options for SSL key, cert and root certs, GH-452
- Remove double click action on cell, GH-455
## 0.11.5 - 2019-12-16
- Add basic SQL keyword autocompletion, GH-443
- SSH Private Key handling update (encrypted keys are supported now), GH-445
- Include Go version into `pgweb --version` output, GH-447
- Fix long table name bug in the sidebar, GH-448
- Add SQL objects (table,views,etc) autocompletion, GH-449
- Include Go version into info API endpoint, GH-450
## 0.11.4 - 2019-10-05
- Fix SQL export filename, GH-438
- Update Docker image to alpine:3.10, GH-439
- Drop unsupported pg_dump options from connection string, GH-441
- Misc code cleanup and formatting, GH-442
## 0.11.3 - 2019-07-24
- Misc: add script to update homebrew formula version, GH-423
- Destructive keyword restriction in read-only mode, GH-421
- Make database object searchable in sidebar, GH-434
- Update lib/pg to 1.1.1, GH-435
## 0.11.2 - 2019-02-15
- Fix table row estimation query for camelcase schemas, GH-414
## 0.11.1 - 2019-01-28
- Typo fixes
- Add Base64 javascript encoder/decoder to replace deprecated window.atob call, GH-405
- Fix startup error when DATABASE_URL is set, GH-406
- Fix user auto detection when USER env var is not set, GH-408
- Switch bindata dependency to use maintained fork: github.com/go-bindata/go-bindata, GH-409
## 0.11.0 - 2018-12-24
- Tweak sidebar database object counters styles, GH-400
- Do not exit with error if local server is not running, GH-399
- Fix SSH host verification check, GH-398
- Scope activity list to current database only, GH-397
- Show current release version and check for updates, GH-396
- Force switch back to default connection settings view, GH-395
- Fix row count estimation bug, GH-394
- Print out failed query SQL and args with --debug flag, GH-393
## 0.10.0 - 2018-11-28
- Fixes relation not found errors when dealing with table names that have uppercase characters, GH-356
- Dockerfile updates, GH-357
- Check if pg_dump is available before running database export, GH-358
- Improvements to CockroachDB integration, GH-365
- Add EstimatedTableRowsCount to avoid count in large tables, GH-366
- Automatically set table filter option to 'equals' if its not set, GH-370
- Dependencies update and switch to dep, GH-375
- Add column context menu item to get numeric stats, GH-377
- Fix issues with connection string builder, GH-378
- Include rows count to numeric stats view on table column, GH-379
- Make localhost to be a default db host, GH-380
- Clear out connection settings/bookmark on login screen when running in session/connect mode
- Add table row context menu with actions, GH-381
- Allow settings url prefix with URL_PREFIX env var, GH-387
- Fix JSON marshal panic when dealing with NaN values, GH-388
- Fix startup behavior when user did not provide a database name, GH-389
## 0.9.12 - 2018-04-23
- Add link to view database connection string format on login page
- Include constraint name under "constraints" tab, GH-343
- Misc CI and config changes
## 0.9.11 - 2017-12-07
- Fix ssl mode for the connection url in the bookmarks, GH-320
- Add support for CORS, GH-321
- Fix custom query results counter for empty queries, GH-322
- Reorganize the table context menu, GH-323
- Disable database connection string text field autocomplete, GH-327
- Add db prefix to the table export files, GH-329
- Add database view context menu with export actions, GH-330
## 0.9.10 - 2017-11-03
- Make idle connection timeout configurable, [GH-282]
- Fix panics when sshinfo is not set on bookmarks, [GH-296]
- Dot now allow using startup bookmark in multi-session mode, [GH-300]
- Add ability to copy table name from the sidebar, [GH-301]
## 0.9.9 - 2017-09-28
- Automatically format JSON data exports, GH-255
- Update Docker image to alpine:3.6, GH-256
- Print out PostgreSQL server version on start in a single-session mode, GH-264
- Record last query timestamp for the client connection, GH-265
- Add context menu for table headers in browse mode (copy name, see unique values), GH-268
- Add ability to export current database dump, GH-270
- Automatically open pgweb in browser on start if its already running, GH-272
- Connect to the database with credentials provided by a third-party backend, GH-266
- Automatically close idle sessions (no activity in 1 hour), GH-275
- Allow connecting via SSH with a custom private key and other fixes, GH-277
- Add options to disable SSH connections, GH-279
## 0.9.8 - 2017-08-04
- Fixed error checking in the API, GH-234
- Fixed activity tab to support PG 9.x versions, GH-237
- Remember sort column and order for pagination, GH-240
- Use `sslmode=disable` for bookmarks without sslmode option, GH-244
- Javascript fixes for IE9-11, GH-245
- Require confirmation for the disconnect, GH-246
- Clean the results table on manual disconnect
## 0.9.7 - 2017-04-04
- Fixed issue with locked session and empty db url, GH-206
- Fixed path rewrite on DB change, GH-212
- Upgraded dependencies, GH-217
- Added ability to specify bookmarks path, GH-218
- Added counter for the number of rows from a custom SQL query, GH-224
- Added new behavior for removing table rows view on custom SQL query page, GH-225
## 0.9.6 - 2016-11-18
- Fixed bug in query base64-encoding, GH-186 - Fixed bug in query base64-encoding, GH-186
- Fixed rows pagination visibility bug, GH-190 - Fixed rows pagination visibility bug, GH-190
@@ -93,7 +385,7 @@
## 0.6.0 - 2015-05-31 ## 0.6.0 - 2015-05-31
- Adds ability to execute only selected SQL query in run command view, [GH-85] - 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 - 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 - Changes sidebar color scheme to a lighter and better looking one
@@ -107,7 +399,7 @@
## 0.5.2 - 2015-04-13 ## 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 tab to view active queries
- Adds column sorting when browsing table contents - Adds column sorting when browsing table contents
- Fixes SQL query view when switching to table structure view - Fixes SQL query view when switching to table structure view
+2 -1
View File
@@ -2,5 +2,6 @@
- Create a new git branch - Create a new git branch
- Make changes - Make changes
- Run tests: `make test` - Run tests: `make test`
- 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` - If you change frontend code (js/css) make sure to rebuild assets: `make assets`
- Open a PR - Open a new pull request
+44 -13
View File
@@ -1,17 +1,48 @@
FROM alpine:3.3 # ------------------------------------------------------------------------------
MAINTAINER Dan Sosedoff <dan.sosedoff@gmail.com> # Builder Stage
# ------------------------------------------------------------------------------
FROM golang:1.25-trixie AS build
ENV PGWEB_VERSION 0.9.5 # Set default build argument for CGO_ENABLED
ARG CGO_ENABLED=0
ENV CGO_ENABLED=${CGO_ENABLED}
RUN \ WORKDIR /build
apk update && \
apk add ca-certificates && \ RUN git config --global --add safe.directory /build
update-ca-certificates && \ COPY go.mod go.sum ./
cd /tmp && \ RUN go mod download
wget https://github.com/sosedoff/pgweb/releases/download/v$PGWEB_VERSION/pgweb_linux_amd64.zip && \ COPY Makefile main.go ./
unzip pgweb_linux_amd64.zip -d /usr/bin && \ COPY static/ static/
mv /usr/bin/pgweb_linux_amd64 /usr/bin/pgweb && \ COPY pkg/ pkg/
rm -f pgweb_linux_amd64.zip COPY .git/ .
RUN make build
# ------------------------------------------------------------------------------
# Fetch signing key
# ------------------------------------------------------------------------------
FROM debian:trixie-slim AS keyring
ADD https://www.postgresql.org/media/keys/ACCC4CF8.asc keyring.asc
RUN apt-get update && \
apt-get install -qq --no-install-recommends gpg
RUN gpg -o keyring.pgp --dearmor keyring.asc
# ------------------------------------------------------------------------------
# Release Stage
# ------------------------------------------------------------------------------
FROM debian:trixie-slim
ARG keyring=/usr/share/keyrings/postgresql-archive-keyring.pgp
COPY --from=keyring /keyring.pgp $keyring
RUN . /etc/os-release && \
echo "deb [signed-by=${keyring}] http://apt.postgresql.org/pub/repos/apt/ ${VERSION_CODENAME}-pgdg main" > /etc/apt/sources.list.d/pgdg.list && \
apt-get update && \
apt-get install -qq --no-install-recommends ca-certificates openssl netcat-openbsd curl postgresql-client
COPY --from=build /build/pgweb /usr/bin/pgweb
RUN useradd --uid 1000 --no-create-home --shell /bin/false pgweb
USER pgweb
EXPOSE 8081 EXPOSE 8081
CMD ["/usr/bin/pgweb", "--bind=0.0.0.0", "--listen=8081"] ENTRYPOINT ["/usr/bin/pgweb", "--bind=0.0.0.0", "--listen=8081"]
-85
View File
@@ -1,85 +0,0 @@
{
"ImportPath": "github.com/sosedoff/pgweb",
"GoVersion": "go1.6",
"GodepVersion": "v71",
"Packages": [
"./..."
],
"Deps": [
{
"ImportPath": "github.com/BurntSushi/toml",
"Comment": "v0.1.0-9-g3883ac1",
"Rev": "3883ac1ce943878302255f538fce319d23226223"
},
{
"ImportPath": "github.com/davecgh/go-spew/spew",
"Rev": "5215b55f46b2b919f50a1df0eaa5886afe4e3b3d"
},
{
"ImportPath": "github.com/gin-gonic/gin",
"Comment": "v0.5",
"Rev": "50e7e427300a7eeeb299a4cd5bc1ebfa04d48528"
},
{
"ImportPath": "github.com/gin-gonic/gin/binding",
"Comment": "v0.5",
"Rev": "50e7e427300a7eeeb299a4cd5bc1ebfa04d48528"
},
{
"ImportPath": "github.com/gin-gonic/gin/render",
"Comment": "v0.5",
"Rev": "50e7e427300a7eeeb299a4cd5bc1ebfa04d48528"
},
{
"ImportPath": "github.com/jessevdk/go-flags",
"Comment": "v1-285-g1679536",
"Rev": "1679536dcc895411a9f5848d9a0250be7856448c"
},
{
"ImportPath": "github.com/jmoiron/sqlx",
"Comment": "sqlx-v1.0-61-gb468c08",
"Rev": "b468c08552f4efac78b94708eb040170a8184c47"
},
{
"ImportPath": "github.com/jmoiron/sqlx/reflectx",
"Comment": "sqlx-v1.0-61-gb468c08",
"Rev": "b468c08552f4efac78b94708eb040170a8184c47"
},
{
"ImportPath": "github.com/julienschmidt/httprouter",
"Comment": "v1-37-gb55664b",
"Rev": "b55664b9e92004aebb7f19a19a9d06271f3a41fc"
},
{
"ImportPath": "github.com/lib/pq",
"Comment": "go1.0-cutoff-56-gdc50b6a",
"Rev": "dc50b6ad2d3ee836442cf3389009c7cd1e64bb43"
},
{
"ImportPath": "github.com/lib/pq/oid",
"Comment": "go1.0-cutoff-56-gdc50b6a",
"Rev": "dc50b6ad2d3ee836442cf3389009c7cd1e64bb43"
},
{
"ImportPath": "github.com/mitchellh/go-homedir",
"Rev": "7d2d8c8a4e078ce3c58736ab521a40b37a504c52"
},
{
"ImportPath": "github.com/pmezard/go-difflib/difflib",
"Rev": "792786c7400a136282c1664665ae0a8db921c6c2"
},
{
"ImportPath": "github.com/stretchr/testify/assert",
"Comment": "v1.1.3",
"Rev": "f390dcf405f7b83c997eac1b06768bb9f44dec18"
},
{
"ImportPath": "golang.org/x/crypto/curve25519",
"Rev": "3760e016850398b85094c4c99e955b8c3dea5711"
},
{
"ImportPath": "golang.org/x/crypto/ssh",
"Rev": "3760e016850398b85094c4c99e955b8c3dea5711"
}
]
}
Generated
-5
View File
@@ -1,5 +0,0 @@
This directory tree is generated automatically by godep.
Please do not edit.
See https://github.com/tools/godep for more information.
+1 -1
View File
@@ -1,6 +1,6 @@
The MIT License (MIT) The MIT License (MIT)
Copyright (c) 2014-2016 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 Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in this software and associated documentation files (the "Software"), to deal in
+33 -47
View File
@@ -1,86 +1,72 @@
TARGETS = darwin/amd64 darwin/386 linux/amd64 linux/386 windows/amd64 windows/386 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') BUILD_TIME ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ" | tr -d '\n')
GO_VERSION ?= $(shell go version | awk {'print $$3'})
DOCKER_RELEASE_TAG = "sosedoff/pgweb:$(shell git describe --abbrev=0 --tags | sed 's/v//')" DOCKER_RELEASE_TAG = "sosedoff/pgweb:$(shell git describe --abbrev=0 --tags | sed 's/v//')"
DOCKER_LATEST_TAG = "sosedoff/pgweb:latest" DOCKER_LATEST_TAG = "sosedoff/pgweb:latest"
BINDATA_IGNORE = $(shell git ls-files -io --exclude-standard $< | sed 's/^/-ignore=/;s/[.]/[.]/g')
LDFLAGS = -s -w
LDFLAGS += -X $(PKG)/pkg/command.GitCommit=$(GIT_COMMIT)
LDFLAGS += -X $(PKG)/pkg/command.BuildTime=$(BUILD_TIME)
LDFLAGS += -X $(PKG)/pkg/command.GoVersion=$(GO_VERSION)
usage: usage:
@echo "" @echo ""
@echo "Task : Description" @echo "Task : Description"
@echo "----------------- : -------------------" @echo "----------------- : -------------------"
@echo "make setup : Install all necessary dependencies"
@echo "make dev : Generate development build" @echo "make dev : Generate development build"
@echo "make test : Run tests"
@echo "make build : Generate production build for current OS" @echo "make build : Generate production build for current OS"
@echo "make bootstrap : Install cross-compilation toolchain"
@echo "make release : Generate binaries for all supported OSes" @echo "make release : Generate binaries for all supported OSes"
@echo "make test : Execute test suite" @echo "make test : Execute test suite"
@echo "make test-all : Execute test suite on multiple PG versions" @echo "make test-all : Execute test suite on multiple PG versions"
@echo "make lint : Execute code linter"
@echo "make clean : Remove all build files and reset assets" @echo "make clean : Remove all build files and reset assets"
@echo "make assets : Generate production assets file"
@echo "make dev-assets : Generate development assets file"
@echo "make docker : Build docker image" @echo "make docker : Build docker image"
@echo "make docker-release : Build and tag docker image" @echo "make docker-release : Build and tag docker image"
@echo "make docker-push : Push docker images to registry" @echo "make docker-push : Push docker images to registry"
@echo "" @echo ""
test: test:
go test -cover ./pkg/... go test -v -race -cover ./pkg/...
test-all: test-all:
@./script/test_all.sh @./script/test_all.sh
@./script/test_cockroach.sh
assets: static/ lint:
go-bindata -o pkg/data/bindata.go -pkg data $(BINDATA_OPTS) $(BINDATA_IGNORE) -ignore=[.]gitignore -ignore=[.]gitkeep $<... golangci-lint run
dev-assets: dev:
@$(MAKE) --no-print-directory assets BINDATA_OPTS="-debug"
dev: dev-assets
go build go build
@echo "You can now execute ./pgweb" @echo "You can now execute ./pgweb"
build: assets build:
go build go build -ldflags '${LDFLAGS}'
@echo "You can now execute ./pgweb" @echo "You can now execute ./pgweb"
release: clean assets install:
go install -ldflags '${LDFLAGS}'
@echo "You can now execute pgweb"
release: clean
@echo "Building binaries..." @echo "Building binaries..."
@gox \ @LDFLAGS='${LDFLAGS}' ./script/build_all.sh
-osarch "$(TARGETS)" \
-ldflags "-X github.com/sosedoff/pgweb/pkg/command.GitCommit=$(GIT_COMMIT) -X github.com/sosedoff/pgweb/pkg/command.BuildTime=$(BUILD_TIME)" \
-output "./bin/pgweb_{{.OS}}_{{.Arch}}"
@echo "Building ARM binaries..."
GOOS=linux GOARCH=arm GOARM=5 go build \
-ldflags "-X github.com/sosedoff/pgweb/pkg/command.GitCommit=$(GIT_COMMIT) -X github.com/sosedoff/pgweb/pkg/command.BuildTime=$(BUILD_TIME)" \
-o "./bin/pgweb_linux_arm_v5"
@echo "\nPackaging binaries...\n"
@./script/package.sh
bootstrap:
gox -build-toolchain
setup:
go get github.com/tools/godep
go get golang.org/x/tools/cmd/cover
go get github.com/mitchellh/gox
go get github.com/jteeuwen/go-bindata/...
godep restore
clean: clean:
@rm -f ./pgweb @echo "Removing all artifacts"
@rm -rf ./bin/* @rm -rf ./pgweb ./bin/*
@rm -f bindata.go
docker: docker:
docker build -t pgweb . docker build --no-cache -t pgweb .
docker-run:
docker run --rm -p 8081:8081 -it pgweb
docker-release: docker-release:
docker build -t $(DOCKER_RELEASE_TAG) . docker build --no-cache -t $(DOCKER_RELEASE_TAG) .
docker build -t $(DOCKER_LATEST_TAG) . docker tag $(DOCKER_RELEASE_TAG) $(DOCKER_LATEST_TAG)
docker images $(DOCKER_RELEASE_TAG)
docker-push: docker-push:
docker push $(DOCKER_RELEASE_TAG) docker push $(DOCKER_RELEASE_TAG)
+25 -39
View File
@@ -1,47 +1,44 @@
# pgweb # 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) [![Release](https://img.shields.io/github/release/sosedoff/pgweb.svg?label=Release)](https://github.com/sosedoff/pgweb/releases)
[![Linux Build](https://img.shields.io/travis/sosedoff/pgweb.svg?label=Linux)](https://travis-ci.org/sosedoff/pgweb) [![Linux Build](https://github.com/sosedoff/pgweb/actions/workflows/checks.yml/badge.svg)](https://github.com/sosedoff/pgweb/actions?query=branch%3Amain)
[![Windows Build](https://img.shields.io/appveyor/ci/sosedoff/pgweb/master.svg?label=Windows)](https://ci.appveyor.com/project/sosedoff/pgweb) [![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 ## Overview
Pgweb is a web-based database browser for PostgreSQL, written in Go and works Pgweb is a web-based database explorer for PostgreSQL, written in Go, and works
on OSX, Linux and Windows machines. Main idea behind using Go for backend development on Mac, Linux and Windows machines. Distributed as a simple binary with zero dependencies.
is to utilize ability of the compiler to produce zero-dependency binaries for Very easy to use and packs just the right amount of features.
multiple platforms. Pgweb was created as an attempt to build very simple and portable
application to work with local or remote PostgreSQL databases.
[See application screenshots](SCREENS.md) [See application screenshots](SCREENS.md)
## Features ## Features
- Cross-platform support OSX/Linux/Windows 32/64-bit - Cross-platform: Mac/Linux/Windows (64bit).
- Simple installation (distributed as a single binary) - Simple installation (distributed as a single binary).
- Zero dependencies - Zero dependencies.
- Works with PostgreSQL 9.1+ - Works with PostgreSQL 9.1+.
- SSH Connections - Supports native SSH tunnels.
- Multiple database sessions - Multiple database sessions.
- Simple database browser - Execute and analyze custom SQL queries.
- Execute and analyze custom SQL queries - Table and query data export to CSV/JSON/XML.
- Table and query data export to CSV/JSON/XML - Query history.
- Query history - Server bookmarks.
- Server bookmarks
Visit [WIKI](https://github.com/sosedoff/pgweb/wiki) for more details Visit [WIKI](https://github.com/sosedoff/pgweb/wiki) for more details.
## Demo ## Demo
Visit https://pgweb-demo.herokuapp.com to see pgweb in action. Visit https://pgweb-demo.fly.dev/ to see Pgweb in action.
## Installation ## Installation
[Precompiled binaries](https://github.com/sosedoff/pgweb/releases) for supported - [Precompiled binaries](https://github.com/sosedoff/pgweb/releases) for supported operating systems are available.
operating systems are available. - [More installation options](https://github.com/sosedoff/pgweb/wiki/Installation)
[More installation options](https://github.com/sosedoff/pgweb/wiki/Installation)
## Usage ## Usage
@@ -61,6 +58,7 @@ Connection URL scheme is also supported:
``` ```
pgweb --url postgres://user:password@host:port/database?sslmode=[mode] pgweb --url postgres://user:password@host:port/database?sslmode=[mode]
pgweb --url "postgres:///database?host=/absolute/path/to/unix/socket/dir"
``` ```
### Multiple database sessions ### Multiple database sessions
@@ -74,13 +72,9 @@ pgweb --sessions
Or set environment variable: 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 ## Testing
Before running tests, make sure you have PostgreSQL server running on `localhost:5432` Before running tests, make sure you have PostgreSQL server running on `localhost:5432`
@@ -110,14 +104,6 @@ make test-all
- Use [issues](https://github.com/sosedoff/pgweb/issues) for any questions - Use [issues](https://github.com/sosedoff/pgweb/issues) for any questions
- Check [wiki](https://github.com/sosedoff/pgweb/wiki) for extra documentation - Check [wiki](https://github.com/sosedoff/pgweb/wiki) for extra documentation
## Contact
- Dan Sosedoff
- [dan.sosedoff@gmail.com](mailto:dan.sosedoff@gmail.com)
- [http://twitter.com/sosedoff](http://twitter.com/sosedoff)
## License ## License
The MIT License (MIT) The MIT License (MIT). See [LICENSE](LICENSE) file for more details.
Copyright (c) 2014-2016 Dan Sosedoff, <dan.sosedoff@gmail.com>
-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"
}
}
}
-27
View File
@@ -1,27 +0,0 @@
clone_folder: c:\go\src\github.com\sosedoff\pgweb
environment:
GOPATH: c:\gopath
services:
- postgresql
install:
- set PATH=%GOPATH%\bin;%PATH%
- echo %PATH%
- echo %GOPATH%
- cd %APPVEYOR_BUILD_FOLDER%
- go env
- go version
- go get github.com/tools/godep
- godep get github.com/mitchellh/gox
- godep get github.com/jteeuwen/go-bindata/...
- godep restore
build_script:
- godep go build
test_script:
- godep go test -cover ./...
deploy: off
@@ -0,0 +1,17 @@
# connect-backend-go
Example Golang backend for Pgweb Connect feature
## Usage
Run the backend:
```bash
go run main.go
```
Configure pgweb:
```bash
pgweb --sessions --connect-backend=http://localhost:4567 --connect-token=test
```
@@ -0,0 +1,52 @@
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
)
type BackendRequest struct {
Resource string `json:"resource"`
Token string `json:"token"`
Headers map[string]string `json:"headers"`
}
type BackendResponse struct {
DatabaseURL string `json:"database_url"`
}
func main() {
resources := map[string]string{
"id1": "postgres://localhost:5432/db1?sslmode=disable",
"id2": "postgres://localhost:5432/db2?sslmode=disable",
"id3": "postgres://localhost:5432/db3?sslmode=disable",
}
http.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) {
backendReq := BackendRequest{}
if err := json.NewDecoder(req.Body).Decode(&backendReq); err != nil {
rw.WriteHeader(400)
fmt.Fprintf(rw, "error while parsing request: %v", err)
return
}
res, ok := resources[backendReq.Resource]
if !ok {
rw.WriteHeader(404)
return
}
resp := BackendResponse{
DatabaseURL: res,
}
json.NewEncoder(rw).Encode(resp)
})
if err := http.ListenAndServe(":4567", nil); err != nil {
log.Fatal(err)
}
}
@@ -0,0 +1,6 @@
source "https://rubygems.org"
gem "sinatra"
gem "json"
gem "puma"
gem "rackup"
@@ -0,0 +1,44 @@
GEM
remote: https://rubygems.org/
specs:
base64 (0.3.0)
json (2.12.2)
logger (1.7.0)
mustermann (3.0.3)
ruby2_keywords (~> 0.0.1)
nio4r (2.7.4)
puma (6.6.0)
nio4r (~> 2.0)
rack (3.1.16)
rack-protection (4.1.1)
base64 (>= 0.1.0)
logger (>= 1.6.0)
rack (>= 3.0.0, < 4)
rack-session (2.1.1)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rackup (2.2.1)
rack (>= 3)
ruby2_keywords (0.0.5)
sinatra (4.1.1)
logger (>= 1.6.0)
mustermann (~> 3.0)
rack (>= 3.0.0, < 4)
rack-protection (= 4.1.1)
rack-session (>= 2.0.0, < 3)
tilt (~> 2.0)
tilt (2.6.0)
PLATFORMS
arm64-darwin-21
arm64-darwin-24
x86_64-linux
DEPENDENCIES
json
puma
rackup
sinatra
BUNDLED WITH
2.3.9
@@ -0,0 +1,18 @@
# connect-backend-ruby
Example Ruby backend for Pgweb Connect feature
## Usage
Install and run the backend:
```bash
bundle install
ruby main.rb
```
Configure pgweb:
```bash
pgweb --sessions --connect-backend=http://localhost:4567 --connect-token=test
```
@@ -0,0 +1,4 @@
require "bundler/setup"
require "./main"
run Sinatra::Application
@@ -0,0 +1,41 @@
require "bundler/setup"
require "sinatra"
# Authentication token
$token = "test"
# List of all available resources
$resources = {
"id1" => "postgres://localhost:5432/db1?sslmode=disable",
"id2" => "postgres://localhost:5432/db2?sslmode=disable",
"id3" => "postgres://localhost:5432/db3?sslmode=disable"
}
helpers do
def error(code, message)
halt(code, JSON.dump(error: message))
end
end
before do
content_type :json
end
post "/" do
req = JSON.load(request.body) || {}
unless req["resource"]
halt 404, "Resource ID required"
end
# Check the resource
resource = $resources[req["resource"]]
if !resource
halt 404, "Invalid resource ID"
end
# Return connection credentials
JSON.dump(
database_url: resource
)
end
+50
View File
@@ -0,0 +1,50 @@
#!/bin/sh
#
# $FreeBSD: $
#
# PROVIDE: pgweb
# REQUIRE: NETWORKING
# KEYWORD:
#
# Add the following lines to /etc/rc.conf to enable pgweb:
# pgweb_enable="YES"
#
# pgweb_enable (bool): Set to YES to enable pgweb
# Default: NO
# pgweb_bind (str): HTTP server host
# Default: localhost
# pgweb_listen (str): HTTP server listen port
# Default: 8081
# pgweb_user (str): pgweb daemon user
# Default: www
# pgweb_group (str): pgweb daemon group
# Default: www
. /etc/rc.subr
name="pgweb"
rcvar="pgweb_enable"
load_rc_config $name
: ${pgweb_user:="www"}
: ${pgweb_group:="www"}
: ${pgweb_enable:="NO"}
: ${pgweb_bind:="localhost"}
: ${pgweb_flags=""}
: ${pgweb_facility:="daemon"}
: ${pgweb_priority:="debug"}
: ${pgweb_listen:="8081"}
procname="/usr/local/bin/${name}"
pidfile="/var/run/${name}.pid"
start_precmd="${name}_precmd"
command=/usr/sbin/daemon
command_args="-S -l ${pgweb_facility} -s ${pgweb_priority} -T ${name} -t ${name} -p ${pidfile} \
${procname} --bind=${pgweb_bind} --listen=${pgweb_listen} ${pgweb_flags}"
pgweb_precmd()
{
install -o ${pgweb_user} /dev/null ${pidfile}
}
run_rc_command "$1"
+67
View File
@@ -0,0 +1,67 @@
#!/bin/sh
### BEGIN INIT INFO
# Provides: pgweb
# Required-Start: $remote_fs $syslog
# Required-Stop: $remote_fs $syslog
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Short-Description: Start daemon at boot time
# Description: Enable service provided by daemon.
### END INIT INFO
# Installation instructions (originally written for Debian)
#
# Save this script into /etc/init.d/pgweb file, make it executable,
# and install it into the boot sequence:
#
# chmod 755 /etc/init.d/pgweb
# update-rc.d pgweb defaults
#
# This script assumes that pgweb binary is located at /home/pgweb/, and that
# there's a bookmark 'server' in /home/pgweb/.pgweb/bookmarks/.
#
NAME="pgweb"
PIDFILE="/var/run/$NAME.pid"
USER="pgweb" # Linux system user
SU="su $USER -s /bin/bash"
TIMEOUT=5 # Time in seconds to wait postgresql to show up
case "$1" in
start)
if [ -f $PIDFILE ]; then
echo "Already running... cat $PIDFILE"
exit 0
fi
# Wait postgresql to show up
while ! test -f /var/run/postgresql/*main.pid
do
sleep 1
TIMEOUT=`expr $TIMEOUT - 1`
if test $TIMEOUT -eq 0; then
exit 1
fi
done
# Ready to start pgweb
PID=`$SU -c '/home/pgweb/pgweb -s -b server >/dev/null & echo $!'` # Note! Logs are lost.
if [ -z $PID ]; then
exit 1
else
echo $PID > $PIDFILE
fi
;;
stop)
PID=`cat $PIDFILE`
kill $PID && rm $PIDFILE
;;
*)
echo "Usage: /etc/init.d/$NAME {start|stop}"
exit 1
;;
esac
exit 0
+10
View File
@@ -0,0 +1,10 @@
description "PgWeb as a Service for Ubuntu 14.04 With Upstart"
start on runlevel [2345]
stop on runlevel [!2345]
respawn
setuid youruser
setgid www-data
exec /usr/bin/pgweb --bind=0.0.0.0 --listen=8081
+5
View File
@@ -0,0 +1,5 @@
host = "localhost"
port = 5432
user = "postgres"
database = "mydatabase"
ssl = "disabled"
+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 -2
View File
@@ -10,7 +10,7 @@
DROP DATABASE IF EXISTS "booktown"; DROP DATABASE IF EXISTS "booktown";
CREATE DATABASE "booktown"; CREATE DATABASE "booktown";
\connect booktown postgres -- \connect booktown postgres
-- --
-- TOC Entry ID 2 (OID 2991542) -- TOC Entry ID 2 (OID 2991542)
-- --
@@ -1293,7 +1293,7 @@ CREATE VIEW "recent_shipments" as SELECT count(*) AS num_shipped, max(shipments.
COPY "publishers" FROM stdin; COPY "publishers" FROM stdin;
150 Kids Can Press Kids Can Press, 29 Birch Ave. Toronto, ON  M4V 1E2 150 Kids Can Press Kids Can Press, 29 Birch Ave. Toronto,ONM4V 1E2
91 Henry Holt & Company, Inc. Henry Holt & Company, Inc. 115 West 18th Street New York, NY 10011 91 Henry Holt & Company, Inc. Henry Holt & Company, Inc. 115 West 18th Street New York, NY 10011
113 O'Reilly & Associates O'Reilly & Associates, Inc. 101 Morris St, Sebastopol, CA 95472 113 O'Reilly & Associates O'Reilly & Associates, Inc. 101 Morris St, Sebastopol, CA 95472
62 Watson-Guptill Publications 1515 Boradway, New York, NY 10036 62 Watson-Guptill Publications 1515 Boradway, New York, NY 10036
+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
+43
View File
@@ -0,0 +1,43 @@
DROP DATABASE IF EXISTS "roach";
CREATE DATABASE "roach";
USE "roach";
CREATE TABLE product_information (
product_id INT PRIMARY KEY NOT NULL,
product_name STRING(50) UNIQUE NOT NULL,
product_description STRING(2000),
category_id STRING(1) NOT NULL CHECK (category_id IN ('A','B','C')),
weight_class INT,
warranty_period INT CONSTRAINT valid_warranty CHECK (warranty_period BETWEEN 0 AND 24),
supplier_id INT,
product_status STRING(20),
list_price DECIMAL(8,2),
min_price DECIMAL(8,2),
catalog_url STRING(50) UNIQUE,
date_added DATE DEFAULT CURRENT_DATE(),
misc JSONB,
CONSTRAINT price_check CHECK (list_price >= min_price),
INDEX date_added_idx (date_added),
INDEX supp_id_prod_status_idx (supplier_id, product_status),
INVERTED INDEX details (misc)
);
INSERT INTO product_information VALUES
(1, 'Product A', 'Text', 'A', NULL, 1),
(2, 'Product B', 'Text', 'B', NULL, 2),
(3, 'Product C', 'Text', 'C', NULL, 3);
CREATE TABLE customers (
id INT PRIMARY KEY,
name STRING
);
CREATE TABLE orders (
id INT PRIMARY KEY,
customer_id INT REFERENCES customers(id) ON DELETE CASCADE
);
INSERT INTO customers VALUES (1, 'Lauren');
INSERT INTO orders VALUES (1,1);
DELETE FROM customers WHERE id = 1;
SELECT * FROM orders;
+61
View File
@@ -0,0 +1,61 @@
---
x-base: &base
environment: &env
POSTGRES_DB: pgweb
POSTGRES_PASSWORD: pgweb
POSTGRES_USER: pgweb
healthcheck:
test: pg_isready -U pgweb -h 127.0.0.1
interval: 5s
services:
postgres18:
<<: *base
image: postgres:18
ports:
- 5433:5432
postgres17:
<<: *base
image: postgres:17
ports:
- 5433:5432
postgres16:
<<: *base
image: postgres:16
ports:
- 5433:5432
postgres15:
<<: *base
image: postgres:15
ports:
- 5433:5432
postgres14:
<<: *base
image: postgres:14
ports:
- 5434:5432
postgres13:
<<: *base
image: postgres:13
ports:
- 5435:5432
postgres12:
<<: *base
image: postgres:12
ports:
- 5436:5432
postgres11:
<<: *base
image: postgres:11
ports:
- 5437:5432
postgres10:
<<: *base
image: postgres:10
ports:
- 5438:5432
postgres9.6:
<<: *base
image: postgres:9.6
ports:
- 5439:5432
+43
View File
@@ -0,0 +1,43 @@
---
services:
postgres:
container_name: pgweb-postgres
image: postgres:15
ports:
- 5433:5432
volumes:
- data:/var/lib/postgresql/data
environment:
POSTGRES_DB: pgweb
POSTGRES_PASSWORD: pgweb
POSTGRES_USER: pgweb
healthcheck:
test: pg_isready -U pgweb -h 127.0.0.1
interval: 5s
networks:
- pgweb
pgweb:
container_name: pgweb
image: sosedoff/pgweb:latest
build: .
environment:
PGWEB_DATABASE_URL: postgres://pgweb:pgweb@pgweb-postgres:5432/pgweb?sslmode=disable
ports:
- 8081:8081
networks:
- pgweb
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD", "nc", "-vz", "127.0.0.1", "8081"]
interval: 5s
volumes:
data:
name: pgweb_postgres
networks:
pgweb:
name: pgweb
+37
View File
@@ -0,0 +1,37 @@
app = "pgweb-demo"
kill_signal = "SIGINT"
kill_timeout = 5
[processes]
web = "pgweb --sessions --bind=0.0.0.0 --metrics --idle-timeout=30"
[[services]]
http_checks = []
internal_port = 8081
processes = ["web"]
protocol = "tcp"
script_checks = []
[services.concurrency]
hard_limit = 25
soft_limit = 20
type = "connections"
[[services.ports]]
force_https = true
handlers = ["http"]
port = 80
[[services.ports]]
handlers = ["tls", "http"]
port = 443
[[services.tcp_checks]]
grace_period = "1s"
interval = "15s"
restart_limit = 0
timeout = "2s"
[metrics]
port = 8081
path = "/metrics"
+64
View File
@@ -0,0 +1,64 @@
module github.com/sosedoff/pgweb
go 1.25
toolchain go1.25.4
require (
github.com/BurntSushi/toml v1.1.0
github.com/ScaleFT/sshkeys v0.0.0-20200327173127-6142f742bca5
github.com/gin-gonic/gin v1.11.0
github.com/jackc/pgpassfile v1.0.0
github.com/jessevdk/go-flags v1.5.0
github.com/jmoiron/sqlx v1.3.5
github.com/lib/pq v1.10.5
github.com/mitchellh/go-homedir v1.1.0
github.com/mr-tron/base58 v1.2.0
github.com/prometheus/client_golang v1.19.1
github.com/sirupsen/logrus v1.9.1
github.com/stretchr/testify v1.11.1
github.com/tuvistavie/securerandom v0.0.0-20140719024926-15512123a948
go.balki.me/anyhttp v0.5.0
golang.org/x/crypto v0.46.0
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.14.2 // indirect
github.com/bytedance/sonic/loader v0.4.0 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dchest/bcrypt_pbkdf v0.0.0-20150205184540-83f37f9c154a // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.1 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.5.0 // indirect
github.com/prometheus/common v0.48.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.58.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.uber.org/mock v0.6.0 // indirect
golang.org/x/arch v0.23.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+151
View File
@@ -0,0 +1,151 @@
github.com/BurntSushi/toml v1.1.0 h1:ksErzDEI1khOiGPgpwuI7x2ebx/uXQNw7xJpn9Eq1+I=
github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/ScaleFT/sshkeys v0.0.0-20200327173127-6142f742bca5 h1:VauE2GcJNZFun2Och6tIT2zJZK1v6jxALQDA9BIji/E=
github.com/ScaleFT/sshkeys v0.0.0-20200327173127-6142f742bca5/go.mod h1:gxOHeajFfvGQh/fxlC8oOKBe23xnnJTif00IFFbiT+o=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dchest/bcrypt_pbkdf v0.0.0-20150205184540-83f37f9c154a h1:saTgr5tMLFnmy/yg3qDTft4rE5DY2uJ/cCxCe3q0XTU=
github.com/dchest/bcrypt_pbkdf v0.0.0-20150205184540-83f37f9c154a/go.mod h1:Bw9BbhOJVNR+t0jCqx2GC6zv0TGBsShs56Y3gfSCvl0=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.1 h1:3rG3+v8pkhRqoQ/88NYNMHYVGYztCOCIZ7UQhu7H+NE=
github.com/goccy/go-yaml v1.19.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jessevdk/go-flags v1.5.0 h1:1jKYvbxEjfUl0fmqTCOfonvskHHXMjBySTLW4y9LFvc=
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=
github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.10.5 h1:J+gdV2cUmX7ZqL2B0lFcW0m+egaHC2V3lpO8nWxyYiQ=
github.com/lib/pq v1.10.5/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg=
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE=
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.58.0 h1:ggY2pvZaVdB9EyojxL1p+5mptkuHyX5MOSv4dgWF4Ug=
github.com/quic-go/quic-go v0.58.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/sirupsen/logrus v1.9.1 h1:Ou41VVR3nMWWmTiEUnj0OlsgOSCUFgsPAOl6jRIcVtQ=
github.com/sirupsen/logrus v1.9.1/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tuvistavie/securerandom v0.0.0-20140719024926-15512123a948 h1:yL0l/u242MzDP6D0B5vGC+wxm5WRY+alQZy+dJk3bFI=
github.com/tuvistavie/securerandom v0.0.0-20140719024926-15512123a948/go.mod h1:a06d/M1pxWi51qiSrfGMHaEydtuXT06nha8N2aNQuXk=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.balki.me/anyhttp v0.5.0 h1:uys0oRciBpZfwtxXAevScKy6amIQBXyDrcV0EtGF5zo=
go.balki.me/anyhttp v0.5.0/go.mod h1:JhfekOIjgVODoVqUCficjpIgmB3wwlB7jhN0eN2EZ/s=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200219091948-cb0a6d8edb6c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+2 -187
View File
@@ -1,194 +1,9 @@
package main package main
import ( import (
"fmt" "github.com/sosedoff/pgweb/pkg/cli"
"os"
"os/exec"
"os/signal"
"github.com/gin-gonic/gin"
"github.com/jessevdk/go-flags"
"github.com/sosedoff/pgweb/pkg/api"
"github.com/sosedoff/pgweb/pkg/bookmarks"
"github.com/sosedoff/pgweb/pkg/client"
"github.com/sosedoff/pgweb/pkg/command"
"github.com/sosedoff/pgweb/pkg/connection"
"github.com/sosedoff/pgweb/pkg/shared"
"github.com/sosedoff/pgweb/pkg/util"
) )
var options command.Options
func exitWithMessage(message string) {
fmt.Println("Error:", message)
os.Exit(1)
}
func initClientUsingBookmark(bookmarkPath, bookmarkName string) (*client.Client, error) {
bookmark, err := bookmarks.GetBookmark(bookmarkPath, bookmarkName)
if err != nil {
return nil, err
}
opt := bookmark.ConvertToOptions()
var connStr string
if opt.Url != "" { // if the bookmark has url set, use it
connStr = opt.Url
} else {
connStr, err = connection.BuildString(opt)
if err != nil {
return nil, fmt.Errorf("error building connection string: %v", err)
}
}
var ssh *shared.SSHInfo
if !bookmark.SSHInfoIsEmpty() {
ssh = &bookmark.Ssh
}
return client.NewFromUrl(connStr, ssh)
}
func initClient() {
if connection.IsBlank(command.Opts) && options.Bookmark == "" {
return
}
var cl *client.Client
var err error
if options.Bookmark != "" {
cl, err = initClientUsingBookmark(bookmarks.Path(), options.Bookmark)
} else {
cl, err = client.New()
}
if err != nil {
exitWithMessage(err.Error())
}
if command.Opts.Debug {
fmt.Println("Server connection string:", cl.ConnectionString)
}
fmt.Println("Connecting to server...")
err = cl.Test()
if err != nil {
exitWithMessage(err.Error())
}
fmt.Println("Checking database objects...")
_, err = cl.Objects()
if err != nil {
exitWithMessage(err.Error())
}
api.DbClient = cl
}
func initOptions() {
err := command.ParseOptions()
if err != nil {
switch err.(type) {
case *flags.Error:
// no need to print error, flags package already does that
default:
fmt.Println(err.Error())
}
os.Exit(1)
}
options = command.Opts
if options.Version {
printVersion()
os.Exit(0)
}
if options.ReadOnly {
msg := `------------------------------------------------------
SECURITY WARNING: You are running pgweb in read-only mode.
This mode is designed for environments where users could potentially delete / change data.
For proper read-only access please follow postgresql role management documentation.
------------------------------------------------------`
fmt.Println(msg)
}
printVersion()
}
func printVersion() {
str := fmt.Sprintf("Pgweb v%s", command.VERSION)
if command.GitCommit != "" {
str += fmt.Sprintf(" (git: %s)", command.GitCommit)
}
fmt.Println(str)
}
func startServer() {
router := gin.Default()
// Enable HTTP basic authentication only if both user and password are set
if options.AuthUser != "" && options.AuthPass != "" {
auth := map[string]string{options.AuthUser: options.AuthPass}
router.Use(gin.BasicAuth(auth))
}
api.SetupRoutes(router)
fmt.Println("Starting server...")
go func() {
err := router.Run(fmt.Sprintf("%v:%v", options.HttpHost, options.HttpPort))
if err != nil {
fmt.Println("Cant start server:", err)
os.Exit(1)
}
}()
}
func handleSignals() {
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt, os.Kill)
<-c
}
func openPage() {
url := fmt.Sprintf("http://%v:%v/%s", options.HttpHost, options.HttpPort, options.Prefix)
fmt.Println("To view database open", url, "in browser")
if options.SkipOpen {
return
}
_, err := exec.Command("which", "open").Output()
if err != nil {
return
}
exec.Command("open", url).Output()
}
func main() { func main() {
initOptions() cli.Run()
initClient()
if api.DbClient != nil {
defer api.DbClient.Close()
}
if !options.Debug {
gin.SetMode("release")
}
// Print memory usage every 30 seconds with debug flag
if options.Debug {
util.StartProfiler()
}
startServer()
openPage()
handleSignals()
} }
+463 -130
View File
@@ -1,35 +1,48 @@
package api package api
import ( import (
"context"
"encoding/base64" "encoding/base64"
"errors"
"fmt" "fmt"
"net/http"
neturl "net/url" neturl "net/url"
"strings" "strings"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/tuvistavie/securerandom"
"github.com/sosedoff/pgweb/pkg/bookmarks" "github.com/sosedoff/pgweb/pkg/bookmarks"
"github.com/sosedoff/pgweb/pkg/client" "github.com/sosedoff/pgweb/pkg/client"
"github.com/sosedoff/pgweb/pkg/command" "github.com/sosedoff/pgweb/pkg/command"
"github.com/sosedoff/pgweb/pkg/connect"
"github.com/sosedoff/pgweb/pkg/connection" "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/pkg/shared"
"github.com/sosedoff/pgweb/static"
) )
var ( var (
DbClient *client.Client // DbClient represents the active database connection in a single-session mode
DbSessions = map[string]*client.Client{} DbClient *client.Client
// DbSessions represents the mapping for client connections
DbSessions *SessionManager
// QueryStore reads the SQL queries stores in the home directory
QueryStore *queries.Store
) )
// DB returns a database connection from the client context
func DB(c *gin.Context) *client.Client { func DB(c *gin.Context) *client.Client {
if command.Opts.Sessions { if command.Opts.Sessions {
return DbSessions[getSessionId(c.Request)] return DbSessions.Get(getSessionId(c.Request))
} else {
return DbClient
} }
return DbClient
} }
// setClient sets the database client connection for the sessions
func setClient(c *gin.Context, newClient *client.Client) error { func setClient(c *gin.Context, newClient *client.Client) error {
currentClient := DB(c) currentClient := DB(c)
if currentClient != nil { if currentClient != nil {
@@ -41,88 +54,173 @@ func setClient(c *gin.Context, newClient *client.Client) error {
return nil return nil
} }
sessionId := getSessionId(c.Request) sid := getSessionId(c.Request)
if sessionId == "" { if sid == "" {
return errors.New("Session ID is required") return errSessionRequired
} }
DbSessions[sessionId] = newClient DbSessions.Add(sid, newClient)
return nil return nil
} }
func GetHome(c *gin.Context) { // GetHome renders the home page
serveStaticAsset("/index.html", c) func GetHome(prefix string) http.Handler {
if prefix != "" {
prefix = "/" + prefix
}
return http.StripPrefix(prefix, static.GetHandler())
} }
func GetAsset(c *gin.Context) { func GetAssets(prefix string) http.Handler {
serveStaticAsset(c.Params.ByName("path"), c) if prefix != "" {
prefix = "/" + prefix + "static/"
} else {
prefix = "/static/"
}
return http.StripPrefix(prefix, static.GetHandler())
} }
// GetSessions renders the number of active sessions
func GetSessions(c *gin.Context) { func GetSessions(c *gin.Context) {
// In debug mode endpoint will return a lot of sensitive information // In debug mode endpoint will return a lot of sensitive information
// like full database connection string and all query history. // like full database connection string and all query history.
if command.Opts.Debug { if command.Opts.Debug {
c.JSON(200, DbSessions) successResponse(c, DbSessions.Sessions())
return return
} }
successResponse(c, gin.H{"sessions": DbSessions.Len()})
c.JSON(200, map[string]int{"sessions": len(DbSessions)})
} }
// ConnectWithBackend creates a new connection based on backend resource
func ConnectWithBackend(c *gin.Context) {
backend := connect.NewBackend(command.Opts.ConnectBackend, command.Opts.ConnectToken)
backend.SetLogger(logger)
if command.Opts.ConnectHeaders != "" {
backend.SetPassHeaders(strings.Split(command.Opts.ConnectHeaders, ","))
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
// Fetch connection credentials
cred, err := backend.FetchCredential(ctx, c.Param("resource"), c.Request.Header)
if err != nil {
badRequest(c, err)
return
}
// Make the new session
sid, err := securerandom.Uuid()
if err != nil {
badRequest(c, err)
return
}
c.Request.Header.Add("x-session-id", sid)
// Connect to the database
cl, err := client.NewFromUrl(cred.DatabaseURL, nil)
if err != nil {
badRequest(c, err)
return
}
cl.External = true
// Finalize session seetup
_, err = cl.Info()
if err == nil {
err = setClient(c, cl)
}
if err != nil {
cl.Close()
badRequest(c, err)
return
}
redirectURI := fmt.Sprintf("/%s?session=%s", command.Opts.Prefix, sid)
c.Redirect(302, redirectURI)
}
// Connect creates a new client connection
func Connect(c *gin.Context) { func Connect(c *gin.Context) {
if command.Opts.LockSession { if command.Opts.LockSession {
c.JSON(400, Error{"Session is locked"}) badRequest(c, errSessionLocked)
return return
} }
var sshInfo *shared.SSHInfo var (
url := c.Request.FormValue("url") cl *client.Client
err error
)
if url == "" { if bookmarkID := c.Request.FormValue("bookmark_id"); bookmarkID != "" {
c.JSON(400, Error{"Url parameter is required"}) cl, err = ConnectWithBookmark(bookmarkID)
return } 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 { if err != nil {
c.JSON(400, Error{err.Error()}) badRequest(c, err)
return
}
if c.Request.FormValue("ssh") != "" {
sshInfo = parseSshInfo(c)
}
cl, err := client.NewFromUrl(url, sshInfo)
if err != nil {
c.JSON(400, Error{err.Error()})
return return
} }
err = cl.Test() err = cl.Test()
if err != nil { if err != nil {
c.JSON(400, Error{err.Error()}) badRequest(c, err)
return return
} }
info, err := cl.Info() info, err := cl.Info()
if err == nil { if err == nil {
err = setClient(c, cl) err = setClient(c, cl)
if err != nil { }
cl.Close() if err != nil {
c.JSON(400, Error{err.Error()}) cl.Close()
return badRequest(c, err)
} return
} }
c.JSON(200, info.Format()[0]) successResponse(c, info.Format()[0])
} }
func ConnectWithURL(c *gin.Context) (*client.Client, error) {
url := c.Request.FormValue("url")
if url == "" {
return nil, errURLRequired
}
url, err := connection.FormatURL(command.Options{
URL: url,
Passfile: command.Opts.Passfile,
})
if err != nil {
return nil, err
}
var sshInfo *shared.SSHInfo
if c.Request.FormValue("ssh") != "" {
sshInfo = parseSshInfo(c)
}
return client.NewFromUrl(url, sshInfo)
}
func ConnectWithBookmark(id string) (*client.Client, error) {
manager := bookmarks.NewManager(command.Opts.BookmarksDir)
bookmark, err := manager.Get(id)
if err != nil {
return nil, err
}
return client.NewFromBookmark(bookmark)
}
// SwitchDb perform database switch for the client connection
func SwitchDb(c *gin.Context) { func SwitchDb(c *gin.Context) {
if command.Opts.LockSession { if command.Opts.LockSession {
c.JSON(400, Error{"Session is locked"}) badRequest(c, errSessionLocked)
return return
} }
@@ -131,139 +229,186 @@ func SwitchDb(c *gin.Context) {
name = c.Request.FormValue("db") name = c.Request.FormValue("db")
} }
if name == "" { if name == "" {
c.JSON(400, Error{"Database name is not provided"}) badRequest(c, errDatabaseNameRequired)
return return
} }
conn := DB(c) conn := DB(c)
if conn == nil { if conn == nil {
c.JSON(400, Error{"Not connected"}) badRequest(c, errNotConnected)
return return
} }
currentUrl, err := neturl.Parse(conn.ConnectionString) // Do not allow switching databases for connections from third-party backends
if err != nil { if conn.External {
c.JSON(400, Error{"Unable to parse current connection string"}) badRequest(c, errSessionLocked)
return return
} }
newStr := strings.Replace(conn.ConnectionString, currentUrl.Path, "/"+name, 1) currentURL, err := neturl.Parse(conn.ConnectionString)
cl, err := client.NewFromUrl(newStr, nil)
if err != nil { if err != nil {
c.JSON(400, Error{err.Error()}) badRequest(c, errInvalidConnString)
return
}
currentURL.Path = name
cl, err := client.NewFromUrl(currentURL.String(), nil)
if err != nil {
badRequest(c, err)
return return
} }
err = cl.Test() err = cl.Test()
if err != nil { if err != nil {
c.JSON(400, Error{err.Error()}) badRequest(c, err)
return return
} }
info, err := cl.Info() info, err := cl.Info()
if err == nil { if err == nil {
err = setClient(c, cl) err = setClient(c, cl)
if err != nil { }
cl.Close() if err != nil {
c.JSON(400, Error{err.Error()}) cl.Close()
return badRequest(c, err)
} return
} }
conn.Close() conn.Close()
c.JSON(200, info.Format()[0]) successResponse(c, info.Format()[0])
} }
// Disconnect closes the current database connection
func Disconnect(c *gin.Context) { func Disconnect(c *gin.Context) {
if command.Opts.LockSession { if command.Opts.LockSession {
c.JSON(400, Error{"Session is locked"}) badRequest(c, errSessionLocked)
return
}
if command.Opts.Sessions {
result := DbSessions.Remove(getSessionId(c.Request))
successResponse(c, gin.H{"success": result})
return return
} }
conn := DB(c) conn := DB(c)
if conn == nil { if conn == nil {
c.JSON(400, Error{"Not connected"}) badRequest(c, errNotConnected)
return return
} }
err := conn.Close() err := conn.Close()
if err != nil { if err != nil {
c.JSON(400, Error{err.Error()}) badRequest(c, err)
return return
} }
c.JSON(200, map[string]bool{"success": true}) DbClient = nil
} successResponse(c, gin.H{"success": true})
func GetDatabases(c *gin.Context) {
names, err := DB(c).Databases()
serveResult(names, err, c)
}
func GetObjects(c *gin.Context) {
result, err := DB(c).Objects()
if err != nil {
c.JSON(400, NewError(err))
return
}
objects := client.ObjectsFromResult(result)
c.JSON(200, objects)
} }
// RunQuery executes the query
func RunQuery(c *gin.Context) { func RunQuery(c *gin.Context) {
query := cleanQuery(c.Request.FormValue("query")) query := cleanQuery(c.Request.FormValue("query"))
if query == "" { if query == "" {
c.JSON(400, NewError(errors.New("Query parameter is missing"))) badRequest(c, errQueryRequired)
return return
} }
HandleQuery(query, c) HandleQuery(query, c)
} }
// ExplainQuery renders query explain plan
func ExplainQuery(c *gin.Context) { func ExplainQuery(c *gin.Context) {
query := cleanQuery(c.Request.FormValue("query")) query := cleanQuery(c.Request.FormValue("query"))
if query == "" { if query == "" {
c.JSON(400, NewError(errors.New("Query parameter is missing"))) badRequest(c, errQueryRequired)
return
}
HandleQuery(fmt.Sprintf("EXPLAIN %s", query), c)
}
// AnalyzeQuery renders query explain plan and analyze profile
func AnalyzeQuery(c *gin.Context) {
query := cleanQuery(c.Request.FormValue("query"))
if query == "" {
badRequest(c, errQueryRequired)
return return
} }
HandleQuery(fmt.Sprintf("EXPLAIN ANALYZE %s", query), c) HandleQuery(fmt.Sprintf("EXPLAIN ANALYZE %s", query), c)
} }
func GetSchemas(c *gin.Context) { // GetDatabases renders a list of all databases on the server
res, err := DB(c).Schemas() func GetDatabases(c *gin.Context) {
serveResult(res, err, c) if command.Opts.LockSession {
} serveResult(c, []string{}, nil)
return
func GetTable(c *gin.Context) { }
var res *client.Result conn := DB(c)
var err error if conn.External {
errorResponse(c, 403, errNotPermitted)
if c.Request.FormValue("type") == "materialized_view" { return
res, err = DB(c).MaterializedView(c.Params.ByName("table"))
} else {
res, err = DB(c).Table(c.Params.ByName("table"))
} }
serveResult(res, err, c) names, err := DB(c).Databases()
serveResult(c, names, err)
} }
// GetObjects renders a list of database objects
func GetObjects(c *gin.Context) {
result, err := DB(c).Objects()
if err != nil {
badRequest(c, err)
return
}
successResponse(c, client.ObjectsFromResult(result))
}
// GetSchemas renders list of available schemas
func GetSchemas(c *gin.Context) {
res, err := DB(c).Schemas()
serveResult(c, res, err)
}
// GetTable renders table information
func GetTable(c *gin.Context) {
var (
res *client.Result
err error
)
db := DB(c)
tableName := c.Params.ByName("table")
switch c.Request.FormValue("type") {
case client.ObjTypeMaterializedView:
res, err = db.MaterializedView(tableName)
case client.ObjTypeFunction:
res, err = db.Function(tableName)
default:
res, err = db.Table(tableName)
}
serveResult(c, res, err)
}
// GetTableRows renders table rows
func GetTableRows(c *gin.Context) { func GetTableRows(c *gin.Context) {
offset, err := parseIntFormValue(c, "offset", 0) offset, err := parseIntFormValue(c, "offset", 0)
if err != nil { if err != nil {
c.JSON(400, NewError(err)) badRequest(c, err)
return return
} }
limit, err := parseIntFormValue(c, "limit", 100) limit, err := parseIntFormValue(c, "limit", 100)
if err != nil { if err != nil {
c.JSON(400, NewError(err)) badRequest(c, err)
return return
} }
@@ -277,13 +422,13 @@ func GetTableRows(c *gin.Context) {
res, err := DB(c).TableRows(c.Params.ByName("table"), opts) res, err := DB(c).TableRows(c.Params.ByName("table"), opts)
if err != nil { if err != nil {
c.JSON(400, NewError(err)) badRequest(c, err)
return return
} }
countRes, err := DB(c).TableRowsCount(c.Params.ByName("table"), opts) countRes, err := DB(c).TableRowsCount(c.Params.ByName("table"), opts)
if err != nil { if err != nil {
c.JSON(400, NewError(err)) badRequest(c, err)
return return
} }
@@ -303,54 +448,114 @@ func GetTableRows(c *gin.Context) {
PerPage: numFetch, PerPage: numFetch,
} }
serveResult(res, err, c) serveResult(c, res, err)
} }
// GetTableInfo renders a selected table information
func GetTableInfo(c *gin.Context) { func GetTableInfo(c *gin.Context) {
res, err := DB(c).TableInfo(c.Params.ByName("table")) res, err := DB(c).TableInfo(c.Params.ByName("table"))
if err == nil {
successResponse(c, res.Format()[0])
} else {
badRequest(c, err)
}
}
if err != nil { // GetHistory renders a list of recent queries
c.JSON(400, NewError(err)) func GetHistory(c *gin.Context) {
successResponse(c, DB(c).History)
}
// GetConnectionInfo renders information about current connection
func GetConnectionInfo(c *gin.Context) {
conn := DB(c)
if err := conn.TestWithTimeout(5 * time.Second); err != nil {
badRequest(c, err)
return return
} }
c.JSON(200, res.Format()[0]) res, err := conn.Info()
}
func GetHistory(c *gin.Context) {
c.JSON(200, DB(c).History)
}
func GetConnectionInfo(c *gin.Context) {
res, err := DB(c).Info()
if err != nil { if err != nil {
c.JSON(400, NewError(err)) badRequest(c, err)
return return
} }
info := res.Format()[0] info := res.Format()[0]
info["session_lock"] = command.Opts.LockSession info["session_lock"] = command.Opts.LockSession
c.JSON(200, info) successResponse(c, info)
} }
// GetServerSettings renders a list of all server settings
func GetServerSettings(c *gin.Context) {
res, err := DB(c).ServerSettings()
serveResult(c, res, err)
}
// GetActivity renders a list of running queries
func GetActivity(c *gin.Context) { func GetActivity(c *gin.Context) {
res, err := DB(c).Activity() res, err := DB(c).Activity()
serveResult(res, err, c) serveResult(c, res, err)
} }
// GetTableIndexes renders a list of database table indexes
func GetTableIndexes(c *gin.Context) { func GetTableIndexes(c *gin.Context) {
res, err := DB(c).TableIndexes(c.Params.ByName("table")) res, err := DB(c).TableIndexes(c.Params.ByName("table"))
serveResult(res, err, c) serveResult(c, res, err)
} }
// GetTableConstraints renders a list of database constraints
func GetTableConstraints(c *gin.Context) { func GetTableConstraints(c *gin.Context) {
res, err := DB(c).TableConstraints(c.Params.ByName("table")) res, err := DB(c).TableConstraints(c.Params.ByName("table"))
serveResult(res, err, c) serveResult(c, res, err)
} }
// GetTablesStats renders data sizes and estimated rows for all tables in the database
func GetTablesStats(c *gin.Context) {
db := DB(c)
connCtx, err := db.GetConnContext()
if err != nil {
badRequest(c, err)
return
}
res, err := db.TablesStats()
if err != nil {
badRequest(c, err)
return
}
format := getQueryParam(c, "format")
if format == "" {
format = "json"
}
// Save as attachment if exporting parameter is set
if getQueryParam(c, "export") == "true" {
ts := time.Now().Format(time.DateOnly)
filename := fmt.Sprintf("pgweb-dbstats-%s-%s.%s", connCtx.Database, ts, format)
c.Writer.Header().Set("Content-disposition", "attachment;filename="+filename)
}
switch format {
case "json":
c.JSON(http.StatusOK, res)
case "csv":
c.Data(http.StatusOK, "text/csv", res.CSV())
case "xml":
c.XML(200, res)
default:
badRequest(c, "invalid format")
}
}
// HandleQuery runs the database query
func HandleQuery(query string, c *gin.Context) { func HandleQuery(query string, c *gin.Context) {
metrics.IncrementQueriesCount()
rawQuery, err := base64.StdEncoding.DecodeString(desanitize64(query)) rawQuery, err := base64.StdEncoding.DecodeString(desanitize64(query))
if err == nil { if err == nil {
query = string(rawQuery) query = string(rawQuery)
@@ -358,7 +563,7 @@ func HandleQuery(query string, c *gin.Context) {
result, err := DB(c).Query(query) result, err := DB(c).Query(query)
if err != nil { if err != nil {
c.JSON(400, NewError(err)) badRequest(c, err)
return return
} }
@@ -377,7 +582,7 @@ func HandleQuery(query string, c *gin.Context) {
case "csv": case "csv":
c.Data(200, "text/csv", result.CSV()) c.Data(200, "text/csv", result.CSV())
case "json": case "json":
c.Data(200, "applicaiton/json", result.JSON()) c.Data(200, "application/json", result.JSON())
case "xml": case "xml":
c.XML(200, result) c.XML(200, result)
default: default:
@@ -385,17 +590,145 @@ func HandleQuery(query string, c *gin.Context) {
} }
} }
// GetBookmarks renders the list of available bookmarks
func GetBookmarks(c *gin.Context) { func GetBookmarks(c *gin.Context) {
bookmarks, err := bookmarks.ReadAll(bookmarks.Path()) manager := bookmarks.NewManager(command.Opts.BookmarksDir)
serveResult(bookmarks, err, c) ids, err := manager.ListIDs()
serveResult(c, ids, err)
} }
// GetInfo renders the pgweb system information
func GetInfo(c *gin.Context) { func GetInfo(c *gin.Context) {
info := map[string]string{ successResponse(c, gin.H{
"version": command.VERSION, "app": command.Info,
"git_sha": command.GitCommit, "features": gin.H{
"build_time": command.BuildTime, "session_lock": command.Opts.LockSession,
"query_timeout": command.Opts.QueryTimeout,
"local_queries": QueryStore != nil,
"bookmarks_only": command.Opts.BookmarksOnly,
},
})
}
// DataExport performs database table export
func DataExport(c *gin.Context) {
db := DB(c)
info, err := db.Info()
if err != nil {
badRequest(c, err)
return
} }
c.JSON(200, info) dump := client.Dump{
Table: strings.TrimSpace(c.Request.FormValue("table")),
}
// Perform validation of pg_dump command availability and compatibility.
// Must be done before the actual command is executed to display errors.
if err := dump.Validate(db.ServerVersion()); err != nil {
badRequest(c, err)
return
}
formattedInfo := info.Format()[0]
filename := formattedInfo["current_database"].(string)
if dump.Table != "" {
filename = filename + "_" + dump.Table
}
filename = sanitizeFilename(filename)
filename = fmt.Sprintf("%s_%s", filename, time.Now().Format("20060102_150405"))
c.Header(
"Content-Disposition",
fmt.Sprintf(`attachment; filename="%s.sql.gz"`, filename),
)
err = dump.Export(c.Request.Context(), db.ConnectionString, c.Writer)
if err != nil {
logger.WithError(err).Error("pg_dump command failed")
badRequest(c, err)
}
}
// GetFunction renders function information
func GetFunction(c *gin.Context) {
res, err := DB(c).Function(c.Param("id"))
serveResult(c, res, err)
}
func GetLocalQueries(c *gin.Context) {
connCtx, err := DB(c).GetConnContext()
if err != nil {
badRequest(c, err)
return
}
storeQueries, err := QueryStore.ReadAll()
if err != nil {
badRequest(c, err)
return
}
queries := []localQuery{}
for _, q := range storeQueries {
if !q.IsPermitted(connCtx.Host, connCtx.User, connCtx.Database, connCtx.Mode) {
continue
}
queries = append(queries, localQuery{
ID: q.ID,
Title: q.Meta.Title,
Description: q.Meta.Description,
Query: cleanQuery(q.Data),
})
}
successResponse(c, queries)
}
func RunLocalQuery(c *gin.Context) {
query, err := QueryStore.Read(c.Param("id"))
if err != nil {
if err == queries.ErrQueryFileNotExist {
query = nil
} else {
badRequest(c, err)
return
}
}
if query == nil {
errorResponse(c, 404, "query not found")
return
}
connCtx, err := DB(c).GetConnContext()
if err != nil {
badRequest(c, err)
return
}
if !query.IsPermitted(connCtx.Host, connCtx.User, connCtx.Database, connCtx.Mode) {
errorResponse(c, 404, "query not found")
return
}
if c.Request.Method == http.MethodGet {
successResponse(c, localQuery{
ID: query.ID,
Title: query.Meta.Title,
Description: query.Meta.Description,
Query: query.Data,
})
return
}
statement := cleanQuery(query.Data)
if statement == "" {
badRequest(c, errQueryRequired)
return
}
HandleQuery(statement, c)
} }
+14 -3
View File
@@ -17,13 +17,24 @@ func Test_assetContentType(t *testing.T) {
"foo.gif": "image/gif", "foo.gif": "image/gif",
"foo.eot": "application/vnd.ms-fontobject", "foo.eot": "application/vnd.ms-fontobject",
"foo.svg": "image/svg+xml", "foo.svg": "image/svg+xml",
"foo.ttf": "application/x-font-ttf",
"foo.woff": "application/x-font-woff",
"foo.foo": "text/plain; charset=utf-8", "foo.foo": "text/plain; charset=utf-8",
"foo": "text/plain; charset=utf-8", "foo": "text/plain; charset=utf-8",
} }
alternatives := map[string]string{
"foo.js": "text/javascript; charset=utf-8",
}
for name, expected := range samples { for name, expected := range samples {
assert.Equal(t, expected, assetContentType(name)) if alternatives[name] == "" {
assert.Equal(t, expected, assetContentType(name))
continue
}
actual := assetContentType(name)
if actual != expected && actual != alternatives[name] {
t.Errorf("expected %v but got %v (alternative value failed)", expected, actual)
}
} }
} }
+16
View File
@@ -0,0 +1,16 @@
package api
import (
"errors"
)
var (
errNotConnected = errors.New("Not connected")
errNotPermitted = errors.New("Not permitted")
errInvalidConnString = errors.New("Invalid connection string")
errSessionRequired = errors.New("Session ID is required")
errSessionLocked = errors.New("Session is locked")
errURLRequired = errors.New("URL parameter is required")
errQueryRequired = errors.New("Query parameter is required")
errDatabaseNameRequired = errors.New("Database name is required")
)
+76 -26
View File
@@ -5,6 +5,7 @@ import (
"mime" "mime"
"net/http" "net/http"
"path/filepath" "path/filepath"
"regexp"
"strconv" "strconv"
"strings" "strings"
@@ -13,30 +14,36 @@ import (
"github.com/sosedoff/pgweb/pkg/shared" "github.com/sosedoff/pgweb/pkg/shared"
) )
var extraMimeTypes = map[string]string{ var (
".icon": "image-x-icon", // Mime types definitions
".ttf": "application/x-font-ttf", extraMimeTypes = map[string]string{
".woff": "application/x-font-woff", ".icon": "image-x-icon",
".eot": "application/vnd.ms-fontobject", ".ttf": "application/x-font-ttf",
".svg": "image/svg+xml", ".woff": "application/x-font-woff",
".html": "text/html; charset-utf-8", ".eot": "application/vnd.ms-fontobject",
} ".svg": "image/svg+xml",
".html": "text/html; charset-utf-8",
}
// Paths that dont require database connection // Paths that dont require database connection
var allowedPaths = map[string]bool{ allowedPaths = map[string]bool{
"/api/sessions": true, "/api/sessions": true,
"/api/info": true, "/api/info": true,
"/api/connect": true, "/api/connect": true,
"/api/bookmarks": true, "/api/bookmarks": true,
"/api/history": true, "/api/history": true,
} }
// List of characters replaced by javascript code to make queries url-safe. // List of characters replaced by javascript code to make queries url-safe.
var base64subs = map[string]string{ base64subs = map[string]string{
"-": "+", "-": "+",
"_": "/", "_": "/",
".": "=", ".": "=",
} }
// Regular expression to remove unwanted characters in filenames
regexCleanFilename = regexp.MustCompile(`[^\w]+`)
)
type Error struct { type Error struct {
Message string `json:"error"` Message string `json:"error"`
@@ -71,6 +78,11 @@ func desanitize64(query string) string {
return query return query
} }
func sanitizeFilename(str string) string {
str = strings.ReplaceAll(str, ".", "_")
return regexCleanFilename.ReplaceAllString(str, "")
}
func getSessionId(req *http.Request) string { func getSessionId(req *http.Request) string {
id := req.Header.Get("x-session-id") id := req.Header.Get("x-session-id")
if id == "" { if id == "" {
@@ -111,10 +123,12 @@ func parseIntFormValue(c *gin.Context, name string, defValue int) (int, error) {
func parseSshInfo(c *gin.Context) *shared.SSHInfo { func parseSshInfo(c *gin.Context) *shared.SSHInfo {
info := shared.SSHInfo{ info := shared.SSHInfo{
Host: c.Request.FormValue("ssh_host"), Host: c.Request.FormValue("ssh_host"),
Port: c.Request.FormValue("ssh_port"), Port: c.Request.FormValue("ssh_port"),
User: c.Request.FormValue("ssh_user"), User: c.Request.FormValue("ssh_user"),
Password: c.Request.FormValue("ssh_password"), Password: c.Request.FormValue("ssh_password"),
Key: c.Request.FormValue("ssh_key"),
KeyPassword: c.Request.FormValue("ssh_key_password"),
} }
if info.Port == "" { if info.Port == "" {
@@ -138,3 +152,39 @@ func assetContentType(name string) string {
return result return result
} }
// Send a query result to client
func serveResult(c *gin.Context, result interface{}, err interface{}) {
if err != nil {
badRequest(c, err)
return
}
successResponse(c, result)
}
// Send successful response back to client
func successResponse(c *gin.Context, data interface{}) {
c.JSON(200, data)
}
// Send an error response back to client
func errorResponse(c *gin.Context, status int, err interface{}) {
var message interface{}
switch v := err.(type) {
case error:
message = v.Error()
case string:
message = v
default:
message = v
}
c.AbortWithStatusJSON(status, gin.H{"status": status, "error": message})
}
// Send a bad request (http 400) back to client
func badRequest(c *gin.Context, err interface{}) {
errorResponse(c, 400, err)
}
+50
View File
@@ -1,10 +1,13 @@
package api package api
import ( import (
"errors"
"net/http" "net/http"
"net/http/httptest"
"net/url" "net/url"
"testing" "testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -27,6 +30,22 @@ func Test_cleanQuery(t *testing.T) {
assert.Equal(t, "test", cleanQuery("--test\ntest\n -- test\n")) assert.Equal(t, "test", cleanQuery("--test\ntest\n -- test\n"))
} }
func Test_sanitizeFilename(t *testing.T) {
examples := map[string]string{
"foo": "foo",
"fooBar": "fooBar",
"foo.bar": "foo_bar",
`"foo"."bar"`: "foo_bar",
"!@#$foo.&&*(&bar": "foo_bar",
}
for given, expected := range examples {
t.Run(given, func(t *testing.T) {
assert.Equal(t, expected, sanitizeFilename(given))
})
}
}
func Test_getSessionId(t *testing.T) { func Test_getSessionId(t *testing.T) {
req := &http.Request{Header: http.Header{}} req := &http.Request{Header: http.Header{}}
req.Header.Add("x-session-id", "token") req.Header.Add("x-session-id", "token")
@@ -36,3 +55,34 @@ func Test_getSessionId(t *testing.T) {
req.URL, _ = url.Parse("http://foobar/?_session_id=token") req.URL, _ = url.Parse("http://foobar/?_session_id=token")
assert.Equal(t, "token", getSessionId(req)) assert.Equal(t, "token", getSessionId(req))
} }
func Test_serveResult(t *testing.T) {
server := gin.Default()
server.GET("/good", func(c *gin.Context) {
serveResult(c, gin.H{"foo": "bar"}, nil)
})
server.GET("/bad", func(c *gin.Context) {
serveResult(c, nil, errors.New("message"))
})
server.GET("/nodata", func(c *gin.Context) {
serveResult(c, nil, nil)
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/good", nil)
server.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
assert.Equal(t, `{"foo":"bar"}`, w.Body.String())
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/bad", nil)
server.ServeHTTP(w, req)
assert.Equal(t, 400, w.Code)
assert.Equal(t, `{"error":"message","status":400}`, w.Body.String())
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/nodata", nil)
server.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
assert.Equal(t, `null`, w.Body.String())
}
+115
View File
@@ -0,0 +1,115 @@
package api
import (
"net/http"
"regexp"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"github.com/sosedoff/pgweb/pkg/command"
)
var (
logger *logrus.Logger
reConnectToken = regexp.MustCompile("/connect/(.*)")
)
func init() {
if logger == nil {
logger = logrus.New()
}
}
// TODO: Move this into server struct when it's ready
func SetLogger(l *logrus.Logger) {
logger = l
}
func RequestLogger(logger *logrus.Logger) gin.HandlerFunc {
debug := logger.Level > logrus.InfoLevel
logForwardedUser := command.Opts.LogForwardedUser
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
// Process request
c.Next()
if !debug {
// Skip static assets logging
if strings.Contains(path, "/static/") {
return
}
path = sanitizeLogPath(path)
}
status := c.Writer.Status()
end := time.Now()
latency := end.Sub(start)
fields := logrus.Fields{
"status": status,
"method": c.Request.Method,
"remote_addr": c.ClientIP(),
"duration": latency.String(),
"duration_ms": latency.Milliseconds(),
"path": path,
}
if reqID := getRequestID(c); reqID != "" {
fields["id"] = reqID
}
if logForwardedUser {
if forwardedUser := c.GetHeader("X-Forwarded-User"); forwardedUser != "" {
fields["forwarded_user"] = forwardedUser
}
if forwardedEmail := c.GetHeader("X-Forwarded-Email"); forwardedEmail != "" {
fields["forwarded_email"] = forwardedEmail
}
}
if err := c.Errors.Last(); err != nil {
fields["error"] = err.Error()
}
// Additional fields for debugging
if debug {
fields["raw_query"] = c.Request.URL.RawQuery
if c.Request.Method != http.MethodGet {
fields["raw_form"] = c.Request.Form
}
}
entry := logger.WithFields(fields)
msg := "http_request"
switch {
case status >= http.StatusBadRequest && status < http.StatusInternalServerError:
entry.Warn(msg)
case status >= http.StatusInternalServerError:
entry.Error(msg)
default:
entry.Info(msg)
}
}
}
func sanitizeLogPath(str string) string {
return reConnectToken.ReplaceAllString(str, "/connect/REDACTED")
}
func getRequestID(c *gin.Context) string {
id := c.GetHeader("x-request-id")
if id == "" {
id = c.GetHeader("x-amzn-trace-id")
}
return id
}
+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}))
}
}
+25 -34
View File
@@ -1,30 +1,28 @@
package api package api
import ( import (
"log"
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/sosedoff/pgweb/pkg/command" "github.com/sosedoff/pgweb/pkg/command"
"github.com/sosedoff/pgweb/pkg/data"
) )
// Middleware function to check database connection status before running queries // Middleware to check database connection status before running queries
func dbCheckMiddleware() gin.HandlerFunc { func dbCheckMiddleware() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
path := strings.Replace(c.Request.URL.Path, command.Opts.Prefix, "", -1) path := strings.Replace(c.Request.URL.Path, command.Opts.Prefix, "", -1)
if allowedPaths[path] == true { // Allow whitelisted paths
if allowedPaths[path] {
c.Next() c.Next()
return return
} }
// We dont care about sessions unless they're enabled // Check if session exists in single-session mode
if !command.Opts.Sessions { if !command.Opts.Sessions {
if DbClient == nil { if DbClient == nil {
c.JSON(400, Error{"Not connected"}) badRequest(c, errNotConnected)
c.Abort()
return return
} }
@@ -32,17 +30,17 @@ func dbCheckMiddleware() gin.HandlerFunc {
return return
} }
sessionId := getSessionId(c.Request) // Determine session ID from the client request
if sessionId == "" { sid := getSessionId(c.Request)
c.JSON(400, Error{"Session ID is required"}) if sid == "" {
c.Abort() badRequest(c, errSessionRequired)
return return
} }
conn := DbSessions[sessionId] // Determine the database connection handle for the session
conn := DbSessions.Get(sid)
if conn == nil { if conn == nil {
c.JSON(400, Error{"Not connected"}) badRequest(c, errNotConnected)
c.Abort()
return return
} }
@@ -50,29 +48,22 @@ func dbCheckMiddleware() gin.HandlerFunc {
} }
} }
// Middleware function to print out request parameters and body for debugging // Middleware to inject CORS headers
func requestInspectMiddleware() gin.HandlerFunc { func corsMiddleware() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
err := c.Request.ParseForm() c.Header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
log.Println("Request params:", err, c.Request.Form) c.Header("Access-Control-Expose-Headers", "*")
c.Header("Access-Control-Allow-Origin", command.Opts.CorsOrigin)
} }
} }
func serveStaticAsset(path string, c *gin.Context) { func requireLocalQueries() gin.HandlerFunc {
data, err := data.Asset("static" + path) return func(c *gin.Context) {
if err != nil { if QueryStore == nil {
c.String(400, err.Error()) badRequest(c, "local queries are disabled")
return return
}
c.Next()
} }
c.Data(200, assetContentType(path), data)
}
func serveResult(result interface{}, err error, c *gin.Context) {
if err != nil {
c.JSON(400, NewError(err))
return
}
c.JSON(200, result)
} }
+48 -31
View File
@@ -2,50 +2,67 @@ package api
import ( import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/sosedoff/pgweb/pkg/command" "github.com/sosedoff/pgweb/pkg/command"
"github.com/sosedoff/pgweb/pkg/metrics"
) )
func SetupMiddlewares(group *gin.RouterGroup) { func SetupMiddlewares(group *gin.RouterGroup) {
if command.Opts.Debug { if command.Opts.Cors {
group.Use(requestInspectMiddleware()) group.Use(corsMiddleware())
} }
group.Use(dbCheckMiddleware()) group.Use(dbCheckMiddleware())
} }
func SetupRoutes(router *gin.Engine) { func SetupRoutes(router *gin.Engine) {
group := router.Group(command.Opts.Prefix) root := router.Group(command.Opts.Prefix)
group.GET("/", GetHome) root.GET("/", gin.WrapH(GetHome(command.Opts.Prefix)))
group.GET("/static/*path", GetAsset) root.GET("/static/*path", gin.WrapH(GetAssets(command.Opts.Prefix)))
root.GET("/connect/:resource", ConnectWithBackend)
api := group.Group("/api") api := root.Group("/api")
{ SetupMiddlewares(api)
SetupMiddlewares(api)
if command.Opts.Sessions { if command.Opts.Sessions {
api.GET("/sessions", GetSessions) api.GET("/sessions", GetSessions)
} }
api.GET("/info", GetInfo) api.GET("/info", GetInfo)
api.POST("/connect", Connect) api.POST("/connect", Connect)
api.POST("/disconnect", Disconnect) api.POST("/disconnect", Disconnect)
api.POST("/switchdb", SwitchDb) api.POST("/switchdb", SwitchDb)
api.GET("/databases", GetDatabases) api.GET("/databases", GetDatabases)
api.GET("/connection", GetConnectionInfo) api.GET("/connection", GetConnectionInfo)
api.GET("/activity", GetActivity) api.GET("/server_settings", GetServerSettings)
api.GET("/schemas", GetSchemas) api.GET("/activity", GetActivity)
api.GET("/objects", GetObjects) api.GET("/schemas", GetSchemas)
api.GET("/tables/:table", GetTable) api.GET("/objects", GetObjects)
api.GET("/tables/:table/rows", GetTableRows) api.GET("/tables/:table", GetTable)
api.GET("/tables/:table/info", GetTableInfo) api.GET("/tables/:table/rows", GetTableRows)
api.GET("/tables/:table/indexes", GetTableIndexes) api.GET("/tables/:table/info", GetTableInfo)
api.GET("/tables/:table/constraints", GetTableConstraints) api.GET("/tables/:table/indexes", GetTableIndexes)
api.GET("/query", RunQuery) api.GET("/tables/:table/constraints", GetTableConstraints)
api.POST("/query", RunQuery) api.GET("/tables_stats", GetTablesStats)
api.GET("/explain", ExplainQuery) api.GET("/functions/:id", GetFunction)
api.POST("/explain", ExplainQuery) api.GET("/query", RunQuery)
api.GET("/history", GetHistory) api.POST("/query", RunQuery)
api.GET("/bookmarks", GetBookmarks) api.GET("/explain", ExplainQuery)
api.POST("/explain", ExplainQuery)
api.GET("/analyze", AnalyzeQuery)
api.POST("/analyze", AnalyzeQuery)
api.GET("/history", GetHistory)
api.GET("/bookmarks", GetBookmarks)
api.GET("/export", DataExport)
api.GET("/local_queries", requireLocalQueries(), GetLocalQueries)
api.GET("/local_queries/:id", requireLocalQueries(), RunLocalQuery)
api.POST("/local_queries/:id", requireLocalQueries(), RunLocalQuery)
}
func SetupMetrics(engine *gin.Engine) {
if command.Opts.MetricsEnabled && command.Opts.MetricsAddr == "" {
// NOTE: We're not supporting the MetricsPath CLI option here to avoid the route conflicts.
engine.GET("/metrics", gin.WrapH(metrics.NewHandler()))
} }
} }
+136
View File
@@ -0,0 +1,136 @@
package api
import (
"sync"
"time"
"github.com/sirupsen/logrus"
"github.com/sosedoff/pgweb/pkg/client"
"github.com/sosedoff/pgweb/pkg/metrics"
)
type SessionManager struct {
logger *logrus.Logger
sessions map[string]*client.Client
mu sync.Mutex
idleTimeout time.Duration
}
func NewSessionManager(logger *logrus.Logger) *SessionManager {
return &SessionManager{
logger: logger,
sessions: map[string]*client.Client{},
mu: sync.Mutex{},
}
}
func (m *SessionManager) SetIdleTimeout(timeout time.Duration) {
m.idleTimeout = timeout
}
func (m *SessionManager) IDs() []string {
m.mu.Lock()
defer m.mu.Unlock()
ids := []string{}
for k := range m.sessions {
ids = append(ids, k)
}
return ids
}
func (m *SessionManager) Sessions() map[string]*client.Client {
m.mu.Lock()
defer m.mu.Unlock()
sessions := make(map[string]*client.Client, len(m.sessions))
for k, v := range m.sessions {
sessions[k] = v
}
return sessions
}
func (m *SessionManager) Get(id string) *client.Client {
m.mu.Lock()
defer m.mu.Unlock()
return m.sessions[id]
}
func (m *SessionManager) Add(id string, conn *client.Client) {
m.mu.Lock()
defer m.mu.Unlock()
m.sessions[id] = conn
metrics.SetSessionsCount(len(m.sessions))
}
func (m *SessionManager) Remove(id string) bool {
m.mu.Lock()
defer m.mu.Unlock()
conn, ok := m.sessions[id]
if ok {
conn.Close()
delete(m.sessions, id)
}
metrics.SetSessionsCount(len(m.sessions))
return ok
}
func (m *SessionManager) Len() int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.sessions)
}
func (m *SessionManager) Cleanup() int {
if m.idleTimeout == 0 {
return 0
}
removed := 0
m.logger.Debug("starting idle sessions cleanup")
defer func() {
m.logger.Debug("removed idle sessions:", removed)
}()
for _, id := range m.staleSessions() {
m.logger.WithField("id", id).Debug("closing stale session")
if m.Remove(id) {
removed++
}
}
return removed
}
func (m *SessionManager) RunPeriodicCleanup() {
m.logger.WithField("timeout", m.idleTimeout).Info("session manager cleanup enabled")
for range time.Tick(time.Minute) {
m.Cleanup()
}
}
func (m *SessionManager) staleSessions() []string {
m.mu.Lock()
defer m.mu.Unlock()
now := time.Now()
ids := []string{}
for id, conn := range m.sessions {
if now.Sub(conn.LastQueryTime()) > m.idleTimeout {
ids = append(ids, id)
}
}
return ids
}
+80
View File
@@ -0,0 +1,80 @@
package api
import (
"sort"
"testing"
"time"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/sosedoff/pgweb/pkg/client"
)
func TestSessionManager(t *testing.T) {
t.Run("return ids", func(t *testing.T) {
manager := NewSessionManager(nil)
assert.Equal(t, []string{}, manager.IDs())
manager.sessions["foo"] = &client.Client{}
manager.sessions["bar"] = &client.Client{}
ids := manager.IDs()
sort.Strings(ids)
assert.Equal(t, []string{"bar", "foo"}, ids)
})
t.Run("get session", func(t *testing.T) {
manager := NewSessionManager(nil)
assert.Nil(t, manager.Get("foo"))
manager.sessions["foo"] = &client.Client{}
assert.NotNil(t, manager.Get("foo"))
})
t.Run("set session", func(t *testing.T) {
manager := NewSessionManager(nil)
assert.Nil(t, manager.Get("foo"))
manager.Add("foo", &client.Client{})
assert.NotNil(t, manager.Get("foo"))
})
t.Run("remove session", func(t *testing.T) {
manager := NewSessionManager(nil)
assert.Nil(t, manager.Get("foo"))
manager.Add("foo", &client.Client{})
assert.NotNil(t, manager.Get("foo"))
assert.True(t, manager.Remove("foo"))
assert.False(t, manager.Remove("foo"))
assert.Nil(t, manager.Get("foo"))
})
t.Run("return len", func(t *testing.T) {
manager := NewSessionManager(nil)
manager.sessions["foo"] = &client.Client{}
manager.sessions["bar"] = &client.Client{}
assert.Equal(t, 2, manager.Len())
})
t.Run("clean up stale sessions", func(t *testing.T) {
manager := NewSessionManager(logrus.New())
conn := &client.Client{}
manager.Add("foo", conn)
assert.Equal(t, 1, manager.Len())
assert.Equal(t, 0, manager.Cleanup())
assert.Equal(t, 1, manager.Len())
res, err := conn.Query("select 1")
assert.Nil(t, res)
assert.Nil(t, err)
manager.SetIdleTimeout(time.Minute)
assert.Equal(t, 1, manager.Cleanup())
assert.Equal(t, 0, manager.Len())
assert.True(t, conn.IsClosed())
})
}
+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"`
}
+35 -92
View File
@@ -1,110 +1,53 @@
package bookmarks package bookmarks
import ( import (
"fmt" "os"
"io/ioutil"
"path/filepath"
"strings"
"github.com/BurntSushi/toml"
"github.com/mitchellh/go-homedir"
"github.com/sosedoff/pgweb/pkg/command" "github.com/sosedoff/pgweb/pkg/command"
"github.com/sosedoff/pgweb/pkg/shared" "github.com/sosedoff/pgweb/pkg/shared"
) )
// Bookmark contains information about bookmarked database connection
type Bookmark struct { type Bookmark struct {
Url string `json:"url"` // Postgres connection URL ID string // ID generated from the filename
Host string `json:"host"` // Server hostname URL string // Postgres connection URL
Port int `json:"port"` // Server port Host string // Server hostname
User string `json:"user"` // Database user Port int // Server port
Password string `json:"password"` // User password User string // Database user
Database string `json:"database"` // Database name UserVar string // Database user environment variable
Ssl string `json:"ssl"` // Connection SSL mode Password string // User password
Ssh shared.SSHInfo `json:"ssh"` // SSH tunnel config PasswordVar string // User password environment variable
Database string // Database name
SSLMode string // Connection SSL mode
SSH *shared.SSHInfo // SSH tunnel config
ReadOnly bool // Enable read-only transaction mode
} }
// SSHInfoIsEmpty returns true if ssh configuration is not provided
func (b Bookmark) SSHInfoIsEmpty() bool { func (b Bookmark) SSHInfoIsEmpty() bool {
return b.Ssh.User == "" && b.Ssh.Host == "" && b.Ssh.Port == "" return b.SSH == nil || b.SSH.User == "" && b.SSH.Host == "" && b.SSH.Port == ""
} }
// ConvertToOptions returns an options struct from connection details
func (b Bookmark) ConvertToOptions() command.Options { 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{ return command.Options{
Url: b.Url, URL: b.URL,
Host: b.Host, Host: b.Host,
Port: b.Port, Port: b.Port,
User: b.User, User: user,
Pass: b.Password, Pass: pass,
DbName: b.Database, DbName: b.Database,
Ssl: b.Ssl, SSLMode: b.SSLMode,
ReadOnly: b.ReadOnly,
} }
} }
func readServerConfig(path string) (Bookmark, error) {
bookmark := Bookmark{}
buff, err := ioutil.ReadFile(path)
if err != nil {
return bookmark, err
}
_, err = toml.Decode(string(buff), &bookmark)
if bookmark.Port == 0 {
bookmark.Port = 5432
}
return bookmark, err
}
func fileBasename(path string) string {
filename := filepath.Base(path)
return strings.Replace(filename, filepath.Ext(path), "", 1)
}
func Path() string {
path, _ := homedir.Dir()
return fmt.Sprintf("%s/.pgweb/bookmarks", path)
}
func ReadAll(path string) (map[string]Bookmark, error) {
results := map[string]Bookmark{}
files, err := ioutil.ReadDir(path)
if err != nil {
return results, err
}
for _, file := range files {
if filepath.Ext(file.Name()) != ".toml" {
continue
}
fullPath := path + "/" + file.Name()
key := fileBasename(file.Name())
config, err := readServerConfig(fullPath)
if err != nil {
fmt.Printf("%s parse error: %s\n", fullPath, err)
continue
}
results[key] = config
}
return results, nil
}
func GetBookmark(bookmarkPath string, bookmarkName string) (Bookmark, error) {
bookmarks, err := ReadAll(bookmarkPath)
if err != nil {
return Bookmark{}, err
}
bookmark, ok := bookmarks[bookmarkName]
if !ok {
return Bookmark{}, fmt.Errorf("couldn't find a bookmark with name %s", bookmarkName)
}
return bookmark, nil
}
+93 -106
View File
@@ -8,128 +8,115 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func Test_Invalid_Bookmark_Files(t *testing.T) { func TestBookmarkSSHInfoIsEmpty(t *testing.T) {
_, err := readServerConfig("foobar") t.Run("empty", func(t *testing.T) {
assert.Error(t, err) info := &shared.SSHInfo{
Host: "",
Port: "",
User: "",
}
_, err = readServerConfig("../../data/invalid.toml") b := Bookmark{SSH: nil}
assert.Error(t, err) assert.True(t, b.SSHInfoIsEmpty())
assert.Equal(t, "Near line 1, key 'invalid encoding': Near line 2: Expected key separator '=', but got '\\n' instead.", err.Error())
b = Bookmark{SSH: info}
assert.True(t, b.SSHInfoIsEmpty())
})
t.Run("populated", func(t *testing.T) {
info := &shared.SSHInfo{
Host: "localhost",
Port: "8080",
User: "postgres",
}
b := Bookmark{SSH: info}
assert.False(t, b.SSHInfoIsEmpty())
})
} }
func Test_Bookmark(t *testing.T) { func TestBookmarkWithVarsConvertToOptions(t *testing.T) {
bookmark, err := readServerConfig("../../data/bookmark.toml") t.Run("literals set", func(t *testing.T) {
b := Bookmark{
User: "user",
UserVar: "",
Password: "password",
PasswordVar: "",
}
assert.Equal(t, nil, err) expOpt := command.Options{
assert.Equal(t, "localhost", bookmark.Host) User: "user",
assert.Equal(t, 5432, bookmark.Port) Pass: "password",
assert.Equal(t, "postgres", bookmark.User) }
assert.Equal(t, "mydatabase", bookmark.Database)
assert.Equal(t, "disable", bookmark.Ssl) opt := b.ConvertToOptions()
assert.Equal(t, "", bookmark.Password) assert.Equal(t, expOpt, opt)
assert.Equal(t, "", bookmark.Url) })
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 Test_Bookmark_URL(t *testing.T) { func TestBookmarkConvertToOptions(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, "", bookmark.Ssl)
assert.Equal(t, "", bookmark.Password)
}
func Test_Bookmarks_Path(t *testing.T) {
assert.NotEqual(t, "/.pgweb/bookmarks", Path())
}
func Test_Basename(t *testing.T) {
assert.Equal(t, "filename", fileBasename("filename.toml"))
assert.Equal(t, "filename", fileBasename("path/filename.toml"))
assert.Equal(t, "filename", fileBasename("~/long/path/filename.toml"))
assert.Equal(t, "filename", fileBasename("filename"))
}
func Test_ReadBookmarks_Invalid(t *testing.T) {
bookmarks, err := ReadAll("foobar")
assert.Error(t, err)
assert.Equal(t, 0, len(bookmarks))
}
func Test_ReadBookmarks(t *testing.T) {
bookmarks, err := ReadAll("../../data")
assert.Equal(t, nil, err)
assert.Equal(t, 2, len(bookmarks))
}
func Test_GetBookmark(t *testing.T) {
expBookmark := Bookmark{
Host: "localhost",
Port: 5432,
User: "postgres",
Password: "",
Database: "mydatabase",
Ssl: "disable",
}
b, err := GetBookmark("../../data", "bookmark")
if assert.NoError(t, err) {
assert.Equal(t, expBookmark, b)
}
_, err = GetBookmark("../../data", "bar")
expErrStr := "couldn't find a bookmark with name bar"
assert.Equal(t, expErrStr, err.Error())
_, err = GetBookmark("foo", "bookmark")
assert.Error(t, err)
}
func Test_Bookmark_SSHInfoIsEmpty(t *testing.T) {
emptySSH := shared.SSHInfo{
Host: "",
Port: "",
User: "",
}
populatedSSH := shared.SSHInfo{
Host: "localhost",
Port: "8080",
User: "postgres",
}
b := Bookmark{Ssh: emptySSH}
assert.True(t, b.SSHInfoIsEmpty())
b.Ssh = populatedSSH
assert.False(t, b.SSHInfoIsEmpty())
}
func Test_ConvertToOptions(t *testing.T) {
b := Bookmark{ b := Bookmark{
Url: "postgres://username:password@host:port/database?sslmode=disable", URL: "postgres://username:password@host:port/database?sslmode=disable",
Host: "localhost", Host: "localhost",
Port: 5432, Port: 5432,
User: "postgres", User: "postgres",
Password: "password", Password: "password",
Database: "mydatabase", Database: "mydatabase",
Ssl: "disable", SSLMode: "disable",
ReadOnly: true,
} }
expOpt := command.Options{ expOpt := command.Options{
Url: "postgres://username:password@host:port/database?sslmode=disable", URL: "postgres://username:password@host:port/database?sslmode=disable",
Host: "localhost", Host: "localhost",
Port: 5432, Port: 5432,
User: "postgres", User: "postgres",
Pass: "password", Pass: "password",
DbName: "mydatabase", DbName: "mydatabase",
Ssl: "disable", SSLMode: "disable",
ReadOnly: true,
} }
opt := b.ConvertToOptions() opt := b.ConvertToOptions()
assert.Equal(t, expOpt, opt) 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())
})
}
+343
View File
@@ -0,0 +1,343 @@
package cli
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"os/signal"
"strings"
"syscall"
"time"
"github.com/gin-gonic/gin"
"github.com/jessevdk/go-flags"
"github.com/sirupsen/logrus"
"github.com/sosedoff/pgweb/pkg/api"
"github.com/sosedoff/pgweb/pkg/bookmarks"
"github.com/sosedoff/pgweb/pkg/client"
"github.com/sosedoff/pgweb/pkg/command"
"github.com/sosedoff/pgweb/pkg/connection"
"github.com/sosedoff/pgweb/pkg/metrics"
"github.com/sosedoff/pgweb/pkg/queries"
"github.com/sosedoff/pgweb/pkg/util"
"go.balki.me/anyhttp"
)
var (
logger *logrus.Logger
options command.Options
readonlyWarning = `
--------------------------------------------------------------------------------
SECURITY WARNING: You are running Pgweb in read-only mode.
This mode is designed for environments where users could potentially delete or change data.
For proper read-only access please follow PostgreSQL role management documentation.
--------------------------------------------------------------------------------`
)
func init() {
logger = logrus.New()
}
func exitWithMessage(message string) {
fmt.Println("Error:", message)
os.Exit(1)
}
func initClientUsingBookmark(baseDir, bookmarkName string) (*client.Client, error) {
manager := bookmarks.NewManager(baseDir)
bookmark, err := manager.Get(bookmarkName)
if err != nil {
return nil, err
}
return client.NewFromBookmark(bookmark)
}
func initClient() {
if connection.IsBlank(command.Opts) && options.Bookmark == "" {
return
}
var cl *client.Client
var err error
if options.Bookmark != "" {
cl, err = initClientUsingBookmark(options.BookmarksDir, options.Bookmark)
} else {
cl, err = client.New()
}
if err != nil {
exitWithMessage(err.Error())
}
if command.Opts.Debug {
fmt.Println("Opening database connection using string:", cl.ConnectionString)
}
retryCount := command.Opts.RetryCount
retryDelay := time.Second * time.Duration(command.Opts.RetryDelay)
fmt.Println("Connecting to server...")
abort, err := testClient(cl, int(retryCount), retryDelay)
if err != nil {
if abort {
exitWithMessage(err.Error())
} else {
return
}
}
if !command.Opts.Sessions {
fmt.Printf("Connected to %s\n", cl.ServerVersionInfo())
}
fmt.Println("Checking database objects...")
_, err = cl.Objects()
if err != nil {
exitWithMessage(err.Error())
}
api.DbClient = cl
}
func initOptions() {
opts, err := command.ParseOptions(os.Args)
if err != nil {
switch errVal := err.(type) {
case *flags.Error:
if errVal.Type == flags.ErrHelp {
fmt.Println("Available environment variables:")
fmt.Println(command.AvailableEnvVars())
}
// no need to print error, flags package already does that
default:
fmt.Println(err.Error())
}
os.Exit(1)
}
command.Opts = opts
options = opts
if options.Version {
printVersion()
os.Exit(0)
}
if err := configureLogger(opts); err != nil {
exitWithMessage(err.Error())
return
}
if options.ReadOnly {
fmt.Println(readonlyWarning)
}
if options.BinaryCodec != "" {
if err := client.SetBinaryCodec(options.BinaryCodec); err != nil {
exitWithMessage(err.Error())
}
}
configureLocalQueryStore()
printVersion()
}
func configureLocalQueryStore() {
if options.Sessions || options.QueriesDir == "" {
return
}
stat, err := os.Stat(options.QueriesDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
logger.Debugf("local queries directory %q does not exist, disabling feature", options.QueriesDir)
} else {
logger.Debugf("local queries feature disabled due to error: %v", err)
}
return
}
if !stat.IsDir() {
logger.Debugf("local queries path %q is not a directory", options.QueriesDir)
return
}
api.QueryStore = queries.NewStore(options.QueriesDir)
}
func configureLogger(opts command.Options) error {
if options.Debug {
logger.SetLevel(logrus.DebugLevel)
} else {
lvl, err := logrus.ParseLevel(opts.LogLevel)
if err != nil {
return err
}
logger.SetLevel(lvl)
}
switch options.LogFormat {
case "text":
logger.SetFormatter(&logrus.TextFormatter{})
case "json":
logger.SetFormatter(&logrus.JSONFormatter{})
default:
return fmt.Errorf("invalid logger format: %v", options.LogFormat)
}
return nil
}
func printVersion() {
fmt.Println(command.VersionString())
}
func startServer() *anyhttp.ServerCtx {
router := gin.New()
router.Use(api.RequestLogger(logger))
router.Use(gin.Recovery())
// Enable HTTP basic authentication only if both user and password are set
if options.AuthUser != "" && options.AuthPass != "" {
auth := map[string]string{options.AuthUser: options.AuthPass}
router.Use(gin.BasicAuth(auth))
}
api.SetLogger(logger)
api.SetupRoutes(router)
api.SetupMetrics(router)
fmt.Println("Starting server...")
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("Can't start server:", err)
if strings.Contains(err.Error(), "address already in use") {
openPage()
}
os.Exit(1)
}
return ctx
}
func startMetricsServer() {
serverAddr := fmt.Sprintf("%v:%v", command.Opts.HTTPHost, command.Opts.HTTPPort)
if options.MetricsAddr == serverAddr {
return
}
err := metrics.StartServer(logger, options.MetricsPath, options.MetricsAddr)
if err != nil {
logger.WithError(err).Fatal("unable to start prometheus metrics server")
}
}
func openPage() {
url := fmt.Sprintf("http://%v:%v/%s", options.HTTPHost, options.HTTPPort, options.Prefix)
fmt.Println("To view database open", url, "in browser")
if options.SkipOpen {
return
}
_, err := exec.Command("which", "open").Output()
if err != nil {
return
}
_, err = exec.Command("open", url).Output()
if err != nil {
fmt.Println("Unable to auto-open pgweb URL:", err)
}
}
// 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()
if api.DbClient != nil {
defer api.DbClient.Close()
}
if !options.Debug {
gin.SetMode("release")
}
// Print memory usage every 30 seconds with debug flag
if options.Debug {
util.StartProfiler()
}
// Start session cleanup worker
if options.Sessions {
api.DbSessions = api.NewSessionManager(logger)
if !command.Opts.DisableConnectionIdleTimeout {
api.DbSessions.SetIdleTimeout(time.Minute * time.Duration(command.Opts.ConnectionIdleTimeout))
go api.DbSessions.RunPeriodicCleanup()
}
}
// 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()
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())
}
}
+369 -51
View File
@@ -1,14 +1,20 @@
package client package client
import ( import (
"context"
"errors"
"fmt" "fmt"
"log"
neturl "net/url" neturl "net/url"
"reflect" "reflect"
"regexp"
"strings" "strings"
"time"
_ "github.com/lib/pq"
"github.com/jmoiron/sqlx" "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/command"
"github.com/sosedoff/pgweb/pkg/connection" "github.com/sosedoff/pgweb/pkg/connection"
"github.com/sosedoff/pgweb/pkg/history" "github.com/sosedoff/pgweb/pkg/history"
@@ -16,22 +22,32 @@ import (
"github.com/sosedoff/pgweb/pkg/statements" "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 { type Client struct {
db *sqlx.DB db *sqlx.DB
tunnel *Tunnel tunnel *Tunnel
serverVersion string
serverType string
lastQueryTime time.Time
queryTimeout time.Duration
readonly bool
closed bool
External bool `json:"external"`
History []history.Record `json:"history"` History []history.Record `json:"history"`
ConnectionString string `json:"connection_string"` 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) { func getSchemaAndTable(str string) (string, string) {
chunks := strings.Split(str, ".") chunks := strings.Split(str, ".")
if len(chunks) == 1 { if len(chunks) == 1 {
@@ -41,7 +57,7 @@ func getSchemaAndTable(str string) (string, string) {
} }
func New() (*Client, error) { func New() (*Client, error) {
str, err := connection.BuildString(command.Opts) str, err := connection.BuildStringFromOptions(command.Opts)
if command.Opts.Debug && str != "" { if command.Opts.Debug && str != "" {
fmt.Println("Creating a new client for:", str) fmt.Println("Creating a new client for:", str)
@@ -62,18 +78,25 @@ func New() (*Client, error) {
History: history.New(), History: history.New(),
} }
client.init()
return &client, nil return &client, nil
} }
func NewFromUrl(url string, sshInfo *shared.SSHInfo) (*Client, error) { func NewFromUrl(url string, sshInfo *shared.SSHInfo) (*Client, error) {
var tunnel *Tunnel var (
tunnel *Tunnel
err error
)
if sshInfo != nil { if sshInfo != nil {
if command.Opts.DisableSSH {
return nil, fmt.Errorf("ssh connections are disabled")
}
if command.Opts.Debug { if command.Opts.Debug {
fmt.Println("Opening SSH tunnel for:", sshInfo) fmt.Println("Opening SSH tunnel for:", sshInfo)
} }
tunnel, err := NewTunnel(sshInfo, url) tunnel, err = NewTunnel(sshInfo, url)
if err != nil { if err != nil {
tunnel.Close() tunnel.Close()
return nil, err return nil, err
@@ -101,6 +124,11 @@ func NewFromUrl(url string, sshInfo *shared.SSHInfo) (*Client, error) {
fmt.Println("Creating a new client for:", url) fmt.Println("Creating a new client for:", url)
} }
uri, err := neturl.Parse(url)
if err == nil && uri.Path == "" {
return nil, fmt.Errorf("Database name is not provided")
}
db, err := sqlx.Open("postgres", url) db, err := sqlx.Open("postgres", url)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -109,19 +137,131 @@ func NewFromUrl(url string, sshInfo *shared.SSHInfo) (*Client, error) {
client := Client{ client := Client{
db: db, db: db,
tunnel: tunnel, tunnel: tunnel,
serverType: postgresType,
ConnectionString: url, ConnectionString: url,
History: history.New(), History: history.New(),
} }
client.init()
return &client, nil return &client, nil
} }
func NewFromBookmark(bookmark *bookmarks.Bookmark) (*Client, error) {
var (
connStr string
err error
)
options := bookmark.ConvertToOptions()
if options.URL != "" {
connStr = options.URL
} else {
connStr, err = connection.BuildStringFromOptions(options)
if err != nil {
return nil, err
}
}
var sshInfo *shared.SSHInfo
if !bookmark.SSHInfoIsEmpty() {
sshInfo = bookmark.SSH
}
client, err := NewFromUrl(connStr, sshInfo)
if err != nil {
return nil, err
}
if bookmark.ReadOnly {
client.readonly = true
}
return client, nil
}
func (client *Client) init() {
if command.Opts.QueryTimeout > 0 {
client.queryTimeout = time.Second * time.Duration(command.Opts.QueryTimeout)
}
client.setServerVersion()
}
func (client *Client) setServerVersion() {
res, err := client.query("SELECT version()")
if err != nil || len(res.Rows) < 1 {
return
}
version := res.Rows[0][0].(string)
match, serverType, serverVersion := detectServerTypeAndVersion(version)
if match {
client.serverType = serverType
client.serverVersion = serverVersion
}
}
func (client *Client) Test() error { 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) { 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) { func (client *Client) Databases() ([]string, error) {
@@ -145,6 +285,10 @@ func (client *Client) MaterializedView(name string) (*Result, error) {
return client.query(statements.MaterializedView, name) return client.query(statements.MaterializedView, name)
} }
func (client *Client) Function(id string) (*Result, error) {
return client.query(statements.Function, id)
}
func (client *Client) TableRows(table string, opts RowsOptions) (*Result, error) { func (client *Client) TableRows(table string, opts RowsOptions) (*Result, error) {
schema, table := getSchemaAndTable(table) schema, table := getSchemaAndTable(table)
sql := fmt.Sprintf(`SELECT * FROM "%s"."%s"`, schema, table) sql := fmt.Sprintf(`SELECT * FROM "%s"."%s"`, schema, table)
@@ -172,9 +316,34 @@ func (client *Client) TableRows(table string, opts RowsOptions) (*Result, error)
return client.query(sql) return client.query(sql)
} }
func (client *Client) TableRowsCount(table string, opts RowsOptions) (*Result, error) { func (client *Client) EstimatedTableRowsCount(table string, opts RowsOptions) (*Result, error) {
schema, table := getSchemaAndTable(table) schema, table := getSchemaAndTable(table)
sql := fmt.Sprintf(`SELECT COUNT(1) FROM "%s"."%s"`, schema, table) result, err := client.query(statements.EstimatedTableRowCount, schema, table)
if err != nil {
return nil, err
}
// float64 to int64 conversion
estimatedRowsCount := result.Rows[0][0].(float64)
result.Rows[0] = Row{int64(estimatedRowsCount)}
return result, nil
}
func (client *Client) TableRowsCount(table string, opts RowsOptions) (*Result, error) {
// Return postgres estimated rows count on empty filter
if opts.Where == "" && client.serverType == postgresType {
res, err := client.EstimatedTableRowsCount(table, opts)
if err != nil {
return nil, err
}
n := res.Rows[0][0].(int64)
if n >= 100000 {
return res, nil
}
}
schema, tableName := getSchemaAndTable(table)
sql := fmt.Sprintf(`SELECT COUNT(1) FROM "%s"."%s"`, schema, tableName)
if opts.Where != "" { if opts.Where != "" {
sql += fmt.Sprintf(" WHERE %s", opts.Where) sql += fmt.Sprintf(" WHERE %s", opts.Where)
@@ -184,7 +353,11 @@ func (client *Client) TableRowsCount(table string, opts RowsOptions) (*Result, e
} }
func (client *Client) TableInfo(table string) (*Result, error) { func (client *Client) TableInfo(table string) (*Result, error) {
return client.query(statements.TableInfo, table) if client.serverType == cockroachType {
return client.query(statements.TableInfoCockroach)
}
schema, table := getSchemaAndTable(table)
return client.query(statements.TableInfo, fmt.Sprintf(`"%s"."%s"`, schema, table))
} }
func (client *Client) TableIndexes(table string) (*Result, error) { func (client *Client) TableIndexes(table string) (*Result, error) {
@@ -209,9 +382,27 @@ func (client *Client) TableConstraints(table string) (*Result, error) {
return res, err 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 // Returns all active queriers on the server
func (client *Client) Activity() (*Result, error) { func (client *Client) Activity() (*Result, error) {
return client.query(statements.Activity) if client.serverType == cockroachType {
return client.query("SHOW QUERIES")
}
version := getMajorMinorVersionString(client.serverVersion)
query := statements.Activity[version]
if query == "" {
query = statements.Activity["default"]
}
return client.query(query)
} }
func (client *Client) Query(query string) (*Result, error) { func (client *Client) Query(query string) (*Result, error) {
@@ -239,42 +430,94 @@ func (client *Client) SetReadOnlyMode() error {
return nil return nil
} }
func (client *Client) query(query string, args ...interface{}) (*Result, error) { func (client *Client) ServerVersionInfo() string {
// We're going to force-set transaction mode on every query. return fmt.Sprintf("%s %s", client.serverType, client.serverVersion)
// This is needed so that default mode could not be changed by user. }
if command.Opts.ReadOnly {
if err := client.SetReadOnlyMode(); err != nil { func (client *Client) ServerVersion() string {
return nil, err return client.serverVersion
} }
func (client *Client) context() (context.Context, context.CancelFunc) {
if client.queryTimeout > 0 {
return context.WithTimeout(context.Background(), client.queryTimeout)
} }
return context.Background(), func() {}
}
action := strings.ToLower(strings.Split(query, " ")[0]) func (client *Client) exec(query string, args ...interface{}) (*Result, error) {
if action == "update" || action == "delete" { ctx, cancel := client.context()
res, err := client.db.Exec(query, args...) defer cancel()
if err != nil {
return nil, err
}
affected, err := res.RowsAffected() queryStart := time.Now()
if err != nil { res, err := client.db.ExecContext(ctx, query, args...)
return nil, err queryFinish := time.Now()
}
result := Result{
Columns: []string{"Rows Affected"},
Rows: []Row{
Row{affected},
},
}
return &result, nil
}
rows, err := client.db.Queryx(query, args...)
if err != nil { if err != nil {
return nil, err return nil, err
} }
affected, err := res.RowsAffected()
if err != nil {
return nil, err
}
result := Result{
Columns: []string{"Rows Affected"},
Rows: []Row{
{affected},
},
Stats: &ResultStats{
ColumnsCount: 1,
RowsCount: 1,
QueryStartTime: queryStart.UTC(),
QueryFinishTime: queryFinish.UTC(),
QueryDuration: queryFinish.Sub(queryStart).Milliseconds(),
},
}
return &result, nil
}
func (client *Client) query(query string, args ...interface{}) (*Result, error) {
if client.db == nil {
return nil, nil
}
// Update the last usage time
defer func() {
client.lastQueryTime = time.Now().UTC()
}()
// We're going to force-set transaction mode on every query.
// This is needed so that default mode could not be changed by user.
if command.Opts.ReadOnly || client.readonly {
if err := client.SetReadOnlyMode(); err != nil {
return nil, err
}
if containsRestrictedKeywords(query) {
return nil, errors.New("query contains keywords not allowed in read-only mode")
}
}
action := strings.ToLower(strings.Split(query, " ")[0])
hasReturnValues := strings.Contains(strings.ToLower(query), " returning ")
if (action == "update" || action == "delete") && !hasReturnValues {
return client.exec(query, args...)
}
ctx, cancel := client.context()
defer cancel()
queryStart := time.Now()
rows, err := client.db.QueryxContext(ctx, query, args...)
queryFinish := time.Now()
if err != nil {
if command.Opts.Debug {
log.Println("Failed query:", query, "\nArgs:", args)
}
return nil, err
}
defer rows.Close() defer rows.Close()
cols, err := rows.Columns() cols, err := rows.Columns()
@@ -282,7 +525,7 @@ func (client *Client) query(query string, args ...interface{}) (*Result, error)
return nil, err return nil, err
} }
// Make sure to never return null colums // Make sure to never return null columns
if cols == nil { if cols == nil {
cols = []string{} cols = []string{}
} }
@@ -312,13 +555,29 @@ func (client *Client) query(query string, args ...interface{}) (*Result, error)
} }
} }
result.PrepareBigints() result.Stats = &ResultStats{
ColumnsCount: len(cols),
RowsCount: len(result.Rows),
QueryStartTime: queryStart.UTC(),
QueryFinishTime: queryFinish.UTC(),
QueryDuration: queryFinish.Sub(queryStart).Milliseconds(),
}
result.PostProcess()
return &result, nil return &result, nil
} }
// Close database connection // Close database connection
func (client *Client) Close() error { func (client *Client) Close() error {
if client.closed {
return nil
}
defer func() {
client.closed = true
client.tunnel = nil
}()
if client.tunnel != nil { if client.tunnel != nil {
client.tunnel.Close() client.tunnel.Close()
} }
@@ -330,6 +589,24 @@ func (client *Client) Close() error {
return nil return nil
} }
func (c *Client) IsClosed() bool {
return c.closed
}
func (c *Client) LastQueryTime() time.Time {
return c.lastQueryTime
}
func (client *Client) IsIdle() bool {
mins := int(time.Since(client.lastQueryTime).Minutes())
if command.Opts.ConnectionIdleTimeout > 0 {
return mins >= command.Opts.ConnectionIdleTimeout
}
return false
}
// Fetch all rows as strings for a single column // Fetch all rows as strings for a single column
func (client *Client) fetchRows(q string) ([]string, error) { func (client *Client) fetchRows(q string) ([]string, error) {
res, err := client.query(q) res, err := client.query(q)
@@ -360,3 +637,44 @@ func (client *Client) hasHistoryRecord(query string) bool {
return result 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
}
+543 -134
View File
@@ -2,12 +2,19 @@ package client
import ( import (
"fmt" "fmt"
"log"
"os" "os"
"os/exec" "os/exec"
"runtime" "runtime"
"sort"
"strings"
"testing" "testing"
"time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/sosedoff/pgweb/pkg/command"
) )
var ( var (
@@ -22,15 +29,37 @@ var (
func mapKeys(data map[string]*Objects) []string { func mapKeys(data map[string]*Objects) []string {
result := []string{} result := []string{}
for k, _ := range data { for k := range data {
result = append(result, k) result = append(result, k)
} }
return result return result
} }
func objectNames(data []Object) []string {
names := make([]string, len(data))
for i, obj := range data {
names[i] = obj.Name
}
sort.Strings(names)
return names
}
// assertMatches is a helper method to check if src slice contains any elements of expected slice
func assertMatches(t *testing.T, expected, src []string) {
assert.NotEqual(t, 0, len(expected))
assert.NotEqual(t, 0, len(src))
for _, val := range expected {
assert.Contains(t, src, val)
}
}
func pgVersion() (int, int) { func pgVersion() (int, int) {
var major, minor 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 return major, minor
} }
@@ -43,6 +72,11 @@ func getVar(name, def string) string {
} }
func initVars() { func initVars() {
// We need to load default options to make sure all stuff works
if err := command.SetDefaultOptions(); err != nil {
log.Fatal(err)
}
serverHost = getVar("PGHOST", "localhost") serverHost = getVar("PGHOST", "localhost")
serverPort = getVar("PGPORT", "5432") serverPort = getVar("PGPORT", "5432")
serverUser = getVar("PGUSER", "postgres") serverUser = getVar("PGUSER", "postgres")
@@ -69,6 +103,9 @@ func onWindows() bool {
} }
func setup() { func setup() {
// No pretty JSON for tests
command.Opts.DisablePrettyJSON = true
out, err := exec.Command( out, err := exec.Command(
testCommands["createdb"], testCommands["createdb"],
"-U", serverUser, "-U", serverUser,
@@ -106,12 +143,12 @@ func setupClient() {
func teardownClient() { func teardownClient() {
if testClient != nil { if testClient != nil {
testClient.db.Close() testClient.Close()
} }
} }
func teardown() { func teardown(t *testing.T, allowFail bool) {
_, err := exec.Command( output, err := exec.Command(
testCommands["dropdb"], testCommands["dropdb"],
"-U", serverUser, "-U", serverUser,
"-h", serverHost, "-h", serverHost,
@@ -119,55 +156,169 @@ func teardown() {
serverDatabase, serverDatabase,
).CombinedOutput() ).CombinedOutput()
if err != nil { if err != nil && strings.Contains(err.Error(), "does not exist") {
fmt.Println("Teardown error:", err) t.Log("Teardown error:", err)
t.Logf("%s\n", output)
if !allowFail {
assert.NoError(t, err)
}
} }
} }
func test_NewClientFromUrl(t *testing.T) { func testNewClientFromURL(t *testing.T) {
url := fmt.Sprintf("postgres://%s@%s:%s/%s?sslmode=disable", serverUser, serverHost, serverPort, serverDatabase) t.Run("postgres prefix", func(t *testing.T) {
client, err := NewFromUrl(url, nil) url := fmt.Sprintf("postgres://%s@%s:%s/%s?sslmode=disable", serverUser, serverHost, serverPort, serverDatabase)
client, err := NewFromUrl(url, nil)
if err != nil { assert.Equal(t, nil, err)
assert.Equal(t, url, client.ConnectionString)
assert.NoError(t, client.Close())
})
t.Run("postgresql prefix", func(t *testing.T) {
url := fmt.Sprintf("postgresql://%s@%s:%s/%s?sslmode=disable", serverUser, serverHost, serverPort, serverDatabase)
client, err := NewFromUrl(url, nil)
assert.Equal(t, nil, err)
assert.Equal(t, url, client.ConnectionString)
assert.NoError(t, client.Close())
})
}
func testClientIdleTime(t *testing.T) {
examples := map[time.Time]bool{
time.Now(): false, // Current time
time.Now().Add(time.Minute * -30): false, // 30 minutes ago
time.Now().Add(time.Minute * -240): true, // 240 minutes ago
time.Now().Add(time.Minute * 30): false, // 30 minutes in future
time.Now().Add(time.Minute * 128): false, // 128 minutes in future
}
for ts, expected := range examples {
testClient.lastQueryTime = ts
assert.Equal(t, expected, testClient.IsIdle())
}
}
func testTest(t *testing.T) {
examples := []struct {
name string
input string
err error
}{
{
name: "success",
input: fmt.Sprintf("postgres://%s@%s:%s/%s?sslmode=disable", serverUser, serverHost, serverPort, serverDatabase),
err: nil,
},
{
name: "connection refused",
input: "postgresql://localhost:5433/dbname",
err: ErrConnectionRefused,
},
{
name: "invalid user",
input: fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable", "foo", serverPassword, serverHost, serverPort, serverDatabase),
err: ErrAuthFailed,
},
{
name: "invalid password",
input: fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable", serverUser, "foo", serverHost, serverPort, serverDatabase),
err: ErrAuthFailed,
},
{
name: "invalid database",
input: fmt.Sprintf("postgres://%s@%s:%s/%s?sslmode=disable", serverUser, serverHost, serverPort, "foo"),
err: ErrDatabaseNotExist,
},
}
for _, ex := range examples {
t.Run(ex.name, func(t *testing.T) {
conn, err := NewFromUrl(ex.input, nil)
require.NoError(t, err)
require.Equal(t, ex.err, conn.Test())
})
}
}
func testInfo(t *testing.T) {
t.Run("normal", func(t *testing.T) {
expected := []string{
"session_user",
"current_user",
"current_database",
"current_schemas",
"inet_client_addr",
"inet_client_port",
"inet_server_addr",
"inet_server_port",
"version",
}
res, err := testClient.Info()
assert.NoError(t, err)
assert.Equal(t, expected, res.Columns)
})
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() defer client.Close()
}
assert.Equal(t, nil, err) res, err := client.Info()
assert.Equal(t, url, client.ConnectionString) assert.NoError(t, err)
assert.Equal(t, expected, res.Columns)
})
} }
func test_NewClientFromUrl2(t *testing.T) { func testActivity(t *testing.T) {
url := fmt.Sprintf("postgresql://%s@%s:%s/%s?sslmode=disable", serverUser, serverHost, serverPort, serverDatabase) expected := []string{"datid", "pid", "query", "query_start", "state", "client_addr"}
client, err := NewFromUrl(url, nil)
if err != nil { res, err := testClient.Activity()
defer client.Close() assert.NoError(t, err)
} assertMatches(t, expected, res.Columns)
assert.Equal(t, nil, err)
assert.Equal(t, url, client.ConnectionString)
} }
func test_Test(t *testing.T) { func testDatabases(t *testing.T) {
assert.Equal(t, nil, testClient.Test())
}
func test_Info(t *testing.T) {
res, err := testClient.Info()
assert.Equal(t, nil, err)
assert.NotEqual(t, nil, res)
}
func test_Databases(t *testing.T) {
res, err := testClient.Databases() res, err := testClient.Databases()
assert.NoError(t, err)
assert.Equal(t, nil, err) assertMatches(t, []string{"booktown", "postgres"}, res)
assert.Contains(t, res, "booktown")
assert.Contains(t, res, "postgres")
} }
func test_Objects(t *testing.T) { func testSchemas(t *testing.T) {
res, err := testClient.Schemas()
assert.NoError(t, err)
assert.Equal(t, []string{"public"}, res)
}
func testObjects(t *testing.T) {
res, err := testClient.Objects() res, err := testClient.Objects()
objects := ObjectsFromResult(res) objects := ObjectsFromResult(res)
@@ -198,24 +349,50 @@ func test_Objects(t *testing.T) {
"text_sorting", "text_sorting",
} }
assert.Equal(t, nil, err) functions := []string{
assert.Equal(t, []string{"schema", "name", "type", "owner"}, res.Columns) "add_shipment",
"add_two_loop",
"books_by_subject",
"compound_word",
"count_by_two",
"double_price",
"extract_all_titles",
"extract_all_titles2",
"extract_title",
"first",
"get_author",
"get_author",
"get_customer_id",
"get_customer_name",
"html_linebreaks",
"in_stock",
"isbn_to_title",
"mixed",
"raise_test",
"ship_item",
"stock_amount",
"test",
"title",
"triple_price",
}
assert.NoError(t, err)
assert.Equal(t, []string{"oid", "schema", "name", "type", "owner", "comment"}, res.Columns)
assert.Equal(t, []string{"public"}, mapKeys(objects)) assert.Equal(t, []string{"public"}, mapKeys(objects))
assert.Equal(t, tables, objects["public"].Tables) assert.Equal(t, tables, objectNames(objects["public"].Tables))
assert.Equal(t, []string{"recent_shipments", "stock_view"}, objects["public"].Views) assertMatches(t, functions, objectNames(objects["public"].Functions))
assert.Equal(t, []string{"author_ids", "book_ids", "shipments_ship_id_seq", "subject_ids"}, objects["public"].Sequences) assert.Equal(t, []string{"recent_shipments", "stock_view"}, objectNames(objects["public"].Views))
assert.Equal(t, []string{"author_ids", "book_ids", "shipments_ship_id_seq", "subject_ids"}, objectNames(objects["public"].Sequences))
major, minor := pgVersion() major, minor := pgVersion()
if minor == 0 || minor >= 3 { if minor == 0 || minor >= 3 {
assert.Equal(t, []string{"m_stock_view"}, objects["public"].MaterializedViews) assert.Equal(t, []string{"m_stock_view"}, objectNames(objects["public"].MaterializedViews))
} else { } else {
t.Logf("Skipping materialized view on %d.%d\n", major, minor) t.Logf("Skipping materialized view on %d.%d\n", major, minor)
} }
} }
func test_Table(t *testing.T) { func testTable(t *testing.T) {
res, err := testClient.Table("books")
columns := []string{ columns := []string{
"column_name", "column_name",
"data_type", "data_type",
@@ -223,126 +400,349 @@ func test_Table(t *testing.T) {
"character_maximum_length", "character_maximum_length",
"character_set_catalog", "character_set_catalog",
"column_default", "column_default",
"comment",
} }
assert.Equal(t, nil, err) res, err := testClient.Table("books")
assert.NoError(t, err)
assert.Equal(t, columns, res.Columns) assert.Equal(t, columns, res.Columns)
assert.Equal(t, 4, len(res.Rows)) assert.Equal(t, 4, len(res.Rows))
} }
func test_TableRows(t *testing.T) { func testTableRows(t *testing.T) {
res, err := testClient.TableRows("books", RowsOptions{}) res, err := testClient.TableRows("books", RowsOptions{})
assert.NoError(t, err)
assert.Equal(t, nil, err)
assert.Equal(t, 4, len(res.Columns)) assert.Equal(t, 4, len(res.Columns))
assert.Equal(t, 15, len(res.Rows)) assert.Equal(t, 15, len(res.Rows))
} }
func test_TableInfo(t *testing.T) { func testTableInfo(t *testing.T) {
res, err := testClient.TableInfo("books") res, err := testClient.TableInfo("books")
assert.NoError(t, err)
assert.Equal(t, nil, err)
assert.Equal(t, 4, len(res.Columns)) assert.Equal(t, 4, len(res.Columns))
assert.Equal(t, 1, len(res.Rows)) assert.Equal(t, 1, len(res.Rows))
} }
func test_TableIndexes(t *testing.T) { func testEstimatedTableRowsCount(t *testing.T) {
res, err := testClient.EstimatedTableRowsCount("books", RowsOptions{})
assert.NoError(t, err)
assert.Equal(t, []string{"reltuples"}, res.Columns)
assert.Equal(t, []Row{{int64(15)}}, res.Rows)
}
func testTableRowsCount(t *testing.T) {
res, err := testClient.TableRowsCount("books", RowsOptions{})
assert.NoError(t, err)
assert.Equal(t, []string{"count"}, res.Columns)
assert.Equal(t, []Row{{int64(15)}}, res.Rows)
}
func testTableRowsCountWithLargeTable(t *testing.T) {
testClient.db.MustExec(`CREATE TABLE large_table AS SELECT s FROM generate_series(1,1000000) s;`)
testClient.db.MustExec(`VACUUM large_table;`)
res, err := testClient.TableRowsCount("large_table", RowsOptions{})
assert.Equal(t, nil, err)
assert.Equal(t, []string{"reltuples"}, res.Columns)
assert.Equal(t, []Row{{int64(1000000)}}, res.Rows)
}
func testTableIndexes(t *testing.T) {
res, err := testClient.TableIndexes("books") res, err := testClient.TableIndexes("books")
assert.NoError(t, err)
assert.Equal(t, nil, err) assert.Equal(t, []string{"index_name", "index_size", "index_definition"}, res.Columns)
assert.Equal(t, 2, len(res.Columns))
assert.Equal(t, 2, len(res.Rows)) assert.Equal(t, 2, len(res.Rows))
} }
func test_TableConstraints(t *testing.T) { func testTableConstraints(t *testing.T) {
res, err := testClient.TableConstraints("editions") res, err := testClient.TableConstraints("editions")
assert.NoError(t, err)
assert.Equal(t, nil, err) assert.Equal(t, []string{"name", "definition"}, res.Columns)
assert.Equal(t, 1, len(res.Columns)) assert.Equal(t, Row{"pkey", "PRIMARY KEY (isbn)"}, res.Rows[0])
assert.Equal(t, 2, len(res.Rows)) assert.Equal(t, Row{"integrity", "CHECK (book_id IS NOT NULL AND edition IS NOT NULL)"}, res.Rows[1])
} }
func test_Query(t *testing.T) { func testTableNameWithCamelCase(t *testing.T) {
res, err := testClient.Query("SELECT * FROM books") testClient.db.MustExec(`CREATE TABLE "exampleTable" (id int, name varchar);`)
testClient.db.MustExec(`INSERT INTO "exampleTable" (id, name) VALUES (1, 'foo'), (2, 'bar');`)
assert.Equal(t, nil, err) _, err := testClient.Table("exampleTable")
assert.Equal(t, 4, len(res.Columns)) assert.NoError(t, err)
assert.Equal(t, 15, len(res.Rows))
_, err = testClient.TableInfo("exampleTable")
assert.NoError(t, err)
_, err = testClient.TableConstraints("exampleTable")
assert.NoError(t, err)
_, err = testClient.TableIndexes("exampleTable")
assert.NoError(t, err)
_, err = testClient.TableRowsCount("exampleTable", RowsOptions{})
assert.NoError(t, err)
_, err = testClient.EstimatedTableRowsCount("exampleTable", RowsOptions{})
assert.NoError(t, err)
} }
func test_QueryError(t *testing.T) { func testQuery(t *testing.T) {
res, err := testClient.Query("SELCT * FROM books") t.Run("basic query", func(t *testing.T) {
res, err := testClient.Query("SELECT * FROM books")
assert.NoError(t, err)
assert.Equal(t, 4, len(res.Columns))
assert.Equal(t, 15, len(res.Rows))
})
assert.NotEqual(t, nil, err) t.Run("error", func(t *testing.T) {
assert.Equal(t, "pq: syntax error at or near \"SELCT\"", err.Error()) res, err := testClient.Query("SELCT * FROM books")
assert.Equal(t, true, res == nil) assert.NotNil(t, err)
assert.Equal(t, "pq: syntax error at or near \"SELCT\"", err.Error())
assert.Nil(t, res)
})
t.Run("invalid table", func(t *testing.T) {
res, err := testClient.Query("SELECT * FROM books2")
assert.NotNil(t, err)
assert.Equal(t, "pq: relation \"books2\" does not exist", err.Error())
assert.Nil(t, res)
})
t.Run("timeout", func(t *testing.T) {
testClient.queryTimeout = time.Millisecond * 100
defer func() {
testClient.queryTimeout = 0
}()
res, err := testClient.query("SELECT pg_sleep(1);")
assert.Equal(t, "pq: canceling statement due to user request", err.Error())
assert.Nil(t, res)
})
} }
func test_QueryInvalidTable(t *testing.T) { func testUpdateQuery(t *testing.T) {
res, err := testClient.Query("SELECT * FROM books2") t.Run("updating data", func(t *testing.T) {
// Add new row
testClient.db.MustExec("INSERT INTO books (id, title) VALUES (8888, 'Test Book'), (8889, 'Test Book 2')")
assert.NotEqual(t, nil, err) // Update without return values
assert.Equal(t, "pq: relation \"books2\" does not exist", err.Error()) res, err := testClient.Query("UPDATE books SET title = 'Foo' WHERE id >= 8888 AND id <= 8889")
assert.Equal(t, true, res == nil) assert.NoError(t, err)
assert.Equal(t, "Rows Affected", res.Columns[0])
assert.Equal(t, int64(2), res.Rows[0][0])
// Update with return values
res, err = testClient.Query("UPDATE books SET title = 'Foo2' WHERE id >= 8888 AND id <= 8889 RETURNING id, title")
assert.NoError(t, err)
assert.Equal(t, []string{"id", "title"}, res.Columns)
assert.Equal(t, Row{int64(8888), "Foo2"}, res.Rows[0])
assert.Equal(t, Row{int64(8889), "Foo2"}, res.Rows[1])
})
t.Run("deleting data", func(t *testing.T) {
// Add new row
testClient.db.MustExec("INSERT INTO books (id, title) VALUES (9999, 'Test Book')")
// Delete the existing row
res, err := testClient.Query("DELETE FROM books WHERE id = 9999")
assert.NoError(t, err)
assert.Equal(t, "Rows Affected", res.Columns[0])
assert.Equal(t, int64(1), res.Rows[0][0])
// Deleting already deleted row
res, err = testClient.Query("DELETE FROM books WHERE id = 9999")
assert.NoError(t, err)
assert.Equal(t, int64(0), res.Rows[0][0])
// Delete with returning value
testClient.db.MustExec("INSERT INTO books (id, title) VALUES (9999, 'Test Book')")
res, err = testClient.Query("DELETE FROM books WHERE id = 9999 RETURNING id")
assert.NoError(t, err)
assert.Equal(t, int64(9999), res.Rows[0][0])
})
} }
func test_TableRowsOrderEscape(t *testing.T) { func testTableRowsOrderEscape(t *testing.T) {
rows, err := testClient.TableRows("dummies", RowsOptions{SortColumn: "isDummy"}) rows, err := testClient.TableRows("dummies", RowsOptions{SortColumn: "isDummy"})
assert.Equal(t, nil, err) assert.NoError(t, err)
assert.Equal(t, 2, len(rows.Rows)) assert.Equal(t, 2, len(rows.Rows))
rows, err = testClient.TableRows("dummies", RowsOptions{SortColumn: "isdummy"}) rows, err = testClient.TableRows("dummies", RowsOptions{SortColumn: "isdummy"})
assert.NotEqual(t, nil, err) assert.NotNil(t, err)
assert.Equal(t, `pq: column "isdummy" does not exist`, err.Error()) assert.Equal(t, `pq: column "isdummy" does not exist`, err.Error())
assert.Equal(t, true, rows == nil) assert.Nil(t, rows)
} }
func test_ResultCsv(t *testing.T) { func testFunctions(t *testing.T) {
res, _ := testClient.Query("SELECT * FROM books ORDER BY id ASC LIMIT 1") funcName := "get_customer_name"
csv := res.CSV() funcID := ""
expected := "id,title,author_id,subject_id\n156,The Tell-Tale Heart,115,9\n" res, err := testClient.Objects()
assert.NoError(t, err)
assert.Equal(t, expected, string(csv)) for _, row := range res.Rows {
if row[2] == funcName {
funcID = row[0].(string)
break
}
}
res, err = testClient.Function("12345")
assert.NoError(t, err)
assertMatches(t, []string{"oid", "proname", "functiondef"}, res.Columns)
assert.Equal(t, 0, len(res.Rows))
res, err = testClient.Function(funcID)
assert.NoError(t, err)
assertMatches(t, []string{"oid", "proname", "functiondef"}, res.Columns)
assert.Equal(t, 1, len(res.Rows))
assert.Equal(t, funcName, res.Rows[0][1])
assert.Contains(t, res.Rows[0][len(res.Columns)-1], "SELECT INTO customer_fname, customer_lname")
} }
func test_History(t *testing.T) { func testResult(t *testing.T) {
_, err := testClient.Query("SELECT * FROM books WHERE id = 12345") t.Run("json", func(t *testing.T) {
query := testClient.History[len(testClient.History)-1].Query result, err := testClient.Query("SELECT * FROM books LIMIT 1")
assert.NoError(t, err)
assert.Equal(t, `[{"author_id":4156,"id":7808,"subject_id":9,"title":"The Shining"}]`, string(result.JSON()))
assert.Equal(t, nil, err) result, err = testClient.Query("SELECT 'NaN'::float AS value;")
assert.Equal(t, "SELECT * FROM books WHERE id = 12345", query) assert.NoError(t, err)
assert.Equal(t, `[{"value":null}]`, string(result.JSON()))
})
t.Run("csv", func(t *testing.T) {
expected := "id,title,author_id,subject_id\n156,The Tell-Tale Heart,115,9\n"
res, err := testClient.Query("SELECT * FROM books ORDER BY id ASC LIMIT 1")
assert.NoError(t, err)
assert.Equal(t, expected, string(res.CSV()))
})
} }
func test_HistoryError(t *testing.T) { func testHistory(t *testing.T) {
_, err := testClient.Query("SELECT * FROM books123") t.Run("success", func(t *testing.T) {
query := testClient.History[len(testClient.History)-1].Query _, err := testClient.Query("SELECT * FROM books WHERE id = 12345")
query := testClient.History[len(testClient.History)-1].Query
assert.NoError(t, err)
assert.Equal(t, "SELECT * FROM books WHERE id = 12345", query)
})
assert.NotEqual(t, nil, err) t.Run("failed query", func(t *testing.T) {
assert.NotEqual(t, "SELECT * FROM books123", query) _, err := testClient.Query("SELECT * FROM books123")
query := testClient.History[len(testClient.History)-1].Query
assert.NotNil(t, err)
assert.NotEqual(t, "SELECT * FROM books123", query)
})
t.Run("unique queries", func(t *testing.T) {
url := fmt.Sprintf("postgres://%s@%s:%s/%s?sslmode=disable", serverUser, serverHost, serverPort, serverDatabase)
client, _ := NewFromUrl(url, nil)
defer client.Close()
for i := 0; i < 3; i++ {
_, err := client.Query("SELECT * FROM books WHERE id = 1")
assert.NoError(t, err)
}
assert.Equal(t, 1, len(client.History))
assert.Equal(t, "SELECT * FROM books WHERE id = 1", client.History[0].Query)
})
} }
func test_HistoryUniqueness(t *testing.T) { func testReadOnlyMode(t *testing.T) {
url := fmt.Sprintf("postgres://%s@%s:%s/%s?sslmode=disable", serverUser, serverHost, serverPort, serverDatabase) command.Opts.ReadOnly = true
client, _ := NewFromUrl(url, nil) defer func() {
command.Opts.ReadOnly = false
}()
client.Query("SELECT * FROM books WHERE id = 1")
client.Query("SELECT * FROM books WHERE id = 1")
assert.Equal(t, 1, len(client.History))
assert.Equal(t, "SELECT * FROM books WHERE id = 1", client.History[0].Query)
}
func test_ReadOnlyMode(t *testing.T) {
url := fmt.Sprintf("postgres://%s@%s:%s/%s?sslmode=disable", serverUser, serverHost, serverPort, serverDatabase) url := fmt.Sprintf("postgres://%s@%s:%s/%s?sslmode=disable", serverUser, serverHost, serverPort, serverDatabase)
client, _ := NewFromUrl(url, nil) client, _ := NewFromUrl(url, nil)
defer client.Close()
err := client.SetReadOnlyMode() err := client.SetReadOnlyMode()
assert.Equal(t, nil, err) assert.NoError(t, err)
_, err = client.Query("CREATE TABLE foobar(id integer);") _, err = client.Query("\nCREATE TABLE foobar(id integer);\n")
assert.NotNil(t, err) assert.NotNil(t, err)
assert.Contains(t, err.Error(), "in a read-only transaction") assert.Error(t, err, "query contains keywords not allowed in read-only mode")
// Turn off guard
_, err = client.db.Exec("SET default_transaction_read_only=off;")
assert.NoError(t, err)
_, err = client.Query("\nCREATE TABLE foobar(id integer);\n")
assert.NotNil(t, err)
assert.Contains(t, err.Error(), "query contains keywords not allowed in read-only mode")
_, err = client.Query("-- CREATE TABLE foobar(id integer);\nSELECT 'foo';")
assert.NoError(t, err)
_, err = client.Query("/* CREATE TABLE foobar(id integer); */ SELECT 'foo';")
assert.NoError(t, err)
t.Run("with local readonly flag", func(t *testing.T) {
command.Opts.ReadOnly = false
client.readonly = true
_, err := client.Query("INSERT INTO foobar(id) VALUES(1)")
assert.Error(t, err, "query contains keywords not allowed in read-only mode")
})
}
func testTablesStats(t *testing.T) {
columns := []string{
"schema_name",
"table_name",
"total_size",
"data_size",
"index_size",
"estimated_rows_count",
"estimated_rows",
"index_to_data_ratio",
"indexes_count",
"columns_count",
}
result, err := testClient.TablesStats()
assert.NoError(t, err)
assert.Equal(t, columns, result.Columns)
}
func testConnContext(t *testing.T) {
result, err := testClient.GetConnContext()
assert.NoError(t, err)
assert.Equal(t, "localhost", result.Host)
assert.Equal(t, "postgres", result.User)
assert.Equal(t, "booktown", result.Database)
assert.Equal(t, "default", result.Mode)
}
func testServerSettings(t *testing.T) {
expectedColumns := []string{
"name",
"setting",
"unit",
"category",
"short_desc",
"extra_desc",
"context",
"vartype",
"source",
"min_val",
"max_val",
"enumvals",
"boot_val",
"reset_val",
"sourcefile",
"sourceline",
"pending_restart",
}
result, err := testClient.ServerSettings()
assert.NoError(t, err)
assert.Equal(t, expectedColumns, result.Columns)
} }
func TestAll(t *testing.T) { func TestAll(t *testing.T) {
@@ -353,30 +753,39 @@ func TestAll(t *testing.T) {
initVars() initVars()
setupCommands() setupCommands()
teardown() teardown(t, false)
setup() setup()
setupClient() setupClient()
test_NewClientFromUrl(t) testNewClientFromURL(t)
test_Test(t) testClientIdleTime(t)
test_Info(t) testTest(t)
test_Databases(t) testInfo(t)
test_Objects(t) testActivity(t)
test_Table(t) testDatabases(t)
test_TableRows(t) testSchemas(t)
test_TableInfo(t) testObjects(t)
test_TableIndexes(t) testTable(t)
test_TableConstraints(t) testTableRows(t)
test_Query(t) testTableInfo(t)
test_QueryError(t) testEstimatedTableRowsCount(t)
test_QueryInvalidTable(t) testTableRowsCount(t)
test_TableRowsOrderEscape(t) testTableRowsCountWithLargeTable(t)
test_ResultCsv(t) testTableIndexes(t)
test_History(t) testTableConstraints(t)
test_HistoryUniqueness(t) testTableNameWithCamelCase(t)
test_HistoryError(t) testQuery(t)
test_ReadOnlyMode(t) testUpdateQuery(t)
testTableRowsOrderEscape(t)
testFunctions(t)
testResult(t)
testHistory(t)
testReadOnlyMode(t)
testDumpExport(t)
testTablesStats(t)
testConnContext(t)
testServerSettings(t)
teardownClient() teardownClient()
teardown() teardown(t, true)
} }
+45
View File
@@ -0,0 +1,45 @@
package client
import (
"encoding/base64"
"encoding/hex"
"fmt"
"github.com/mr-tron/base58"
)
const (
CodecNone = "none"
CodecHex = "hex"
CodecBase58 = "base58"
CodecBase64 = "base64"
)
var (
// BinaryCodec sets the serialization format of binary data
BinaryCodec = CodecBase64
)
func SetBinaryCodec(codec string) error {
switch codec {
case CodecNone, CodecHex, CodecBase58, CodecBase64:
BinaryCodec = codec
default:
return fmt.Errorf("invalid binary codec: %v", codec)
}
return nil
}
func encodeBinaryData(data []byte, codec string) string {
switch codec {
case CodecHex:
return hex.EncodeToString(data)
case CodecBase58:
return base58.Encode(data)
case CodecBase64:
return base64.StdEncoding.EncodeToString(data)
default:
return string(data)
}
}
+51
View File
@@ -0,0 +1,51 @@
package client
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
)
func TestSetBinaryCodec(t *testing.T) {
examples := []struct {
input string
err error
}{
{input: CodecNone, err: nil},
{input: CodecBase58, err: nil},
{input: CodecBase64, err: nil},
{input: CodecHex, err: nil},
{input: "foobar", err: errors.New("invalid binary codec: foobar")},
}
for _, ex := range examples {
t.Run(ex.input, func(t *testing.T) {
val := BinaryCodec
defer func() {
BinaryCodec = val
}()
assert.Equal(t, ex.err, SetBinaryCodec(ex.input))
})
}
}
func Test_encodeBinaryData(t *testing.T) {
examples := []struct {
input string
expected string
encoding string
}{
{input: "hello world", expected: "hello world", encoding: CodecNone},
{input: "hello world", expected: "StV1DL6CwTryKyV", encoding: CodecBase58},
{input: "hello world", expected: "aGVsbG8gd29ybGQ=", encoding: CodecBase64},
{input: "hello world", expected: "68656c6c6f20776f726c64", encoding: CodecHex},
}
for _, ex := range examples {
t.Run(ex.input, func(t *testing.T) {
assert.Equal(t, ex.expected, encodeBinaryData([]byte(ex.input), ex.encoding))
})
}
}
+93
View File
@@ -0,0 +1,93 @@
package client
import (
"bytes"
"context"
"fmt"
"io"
"net/url"
"os/exec"
"strings"
)
var (
unsupportedDumpOptions = []string{
"search_path",
}
)
// Dump represents a database dump
type Dump struct {
Table string
}
// Validate checks availability and version of pg_dump CLI
func (d *Dump) Validate(serverVersion string) error {
out := bytes.NewBuffer(nil)
cmd := exec.Command("pg_dump", "--version")
cmd.Stdout = out
cmd.Stderr = out
if err := cmd.Run(); err != nil {
return fmt.Errorf("pg_dump command failed: %s", out.Bytes())
}
detected, dumpVersion := detectDumpVersion(out.String())
if detected && serverVersion != "" {
satisfied := checkVersionRequirement(dumpVersion, serverVersion)
if !satisfied {
return fmt.Errorf("pg_dump version %v not compatible with server version %v", dumpVersion, serverVersion)
}
}
return nil
}
// Export streams the database dump to the specified writer
func (d *Dump) Export(ctx context.Context, connstr string, writer io.Writer) error {
if str, err := removeUnsupportedOptions(connstr); err != nil {
return err
} else {
connstr = str
}
opts := []string{
"--no-owner", // skip restoration of object ownership in plain-text format
"--clean", // clean (drop) database objects before recreating
"--compress", "6", // compression level for compressed formats
}
if d.Table != "" {
opts = append(opts, []string{"--table", d.Table}...)
}
opts = append(opts, connstr)
errOutput := bytes.NewBuffer(nil)
cmd := exec.CommandContext(ctx, "pg_dump", opts...)
cmd.Stdout = writer
cmd.Stderr = errOutput
if err := cmd.Run(); err != nil {
return fmt.Errorf("error: %s. output: %s", err.Error(), errOutput.Bytes())
}
return nil
}
// removeUnsupportedOptions removes any options unsupported for making a db dump
func removeUnsupportedOptions(input string) (string, error) {
uri, err := url.Parse(input)
if err != nil {
return "", err
}
q := uri.Query()
for _, opt := range unsupportedDumpOptions {
q.Del(opt)
q.Del(strings.ToUpper(opt))
}
uri.RawQuery = q.Encode()
return uri.String(), nil
}
+55
View File
@@ -0,0 +1,55 @@
package client
import (
"context"
"fmt"
"os"
"testing"
"github.com/stretchr/testify/assert"
)
func testDumpExport(t *testing.T) {
url := fmt.Sprintf("postgres://%s@%s:%s/%s?sslmode=disable", serverUser, serverHost, serverPort, serverDatabase)
savePath := "/tmp/dump.sql.gz"
os.Remove(savePath)
saveFile, err := os.Create(savePath)
if err != nil {
t.Fatal(err.Error())
}
defer func() {
saveFile.Close()
os.Remove(savePath)
}()
dump := Dump{}
// Test for pg_dump presence
assert.NoError(t, dump.Validate("10.0"))
assert.NoError(t, dump.Validate(""))
assert.Contains(t, dump.Validate("20").Error(), "not compatible with server version 20")
// Test full db dump
err = dump.Export(context.Background(), url, saveFile)
assert.NoError(t, err)
// Test nonexistent database
invalidURL := fmt.Sprintf("postgres://%s@%s:%s/%s?sslmode=disable", serverUser, serverHost, serverPort, "foobar")
err = dump.Export(context.Background(), invalidURL, saveFile)
assert.Contains(t, err.Error(), `database "foobar" does not exist`)
// Test dump of non existent db
dump = Dump{Table: "foobar"}
err = dump.Export(context.Background(), url, saveFile)
assert.NotNil(t, err)
assert.Contains(t, err.Error(), "no matching tables were found")
// Should drop "search_path" param from URI
dump = Dump{}
searchPathURL := fmt.Sprintf("postgres://%s@%s:%s/%s?sslmode=disable&search_path=private", serverUser, serverHost, serverPort, serverDatabase)
err = dump.Export(context.Background(), searchPathURL, saveFile)
assert.NoError(t, err)
}
+123 -56
View File
@@ -5,69 +5,124 @@ import (
"encoding/csv" "encoding/csv"
"encoding/json" "encoding/json"
"fmt" "fmt"
"reflect" "log"
"math"
"strconv" "strconv"
"time" "time"
"github.com/sosedoff/pgweb/pkg/command"
) )
type Row []interface{} const (
ObjTypeTable = "table"
ObjTypeView = "view"
ObjTypeMaterializedView = "materialized_view"
ObjTypeSequence = "sequence"
ObjTypeFunction = "function"
)
type Pagination struct { type (
Rows int64 `json:"rows_count"` // Row represents a single row of data
Page int64 `json:"page"` Row []interface{}
Pages int64 `json:"pages_count"`
PerPage int64 `json:"per_page"`
}
type Result struct { // RowsOptions contains a list of parameters for table browsing requests
Pagination *Pagination `json:"pagination,omitempty"` RowsOptions struct {
Columns []string `json:"columns"` Where string // Custom filter
Rows []Row `json:"rows"` 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)
}
type Objects struct { Pagination struct {
Tables []string `json:"table"` Rows int64 `json:"rows_count"`
Views []string `json:"view"` Page int64 `json:"page"`
MaterializedViews []string `json:"materialized_view"` Pages int64 `json:"pages_count"`
Sequences []string `json:"sequence"` PerPage int64 `json:"per_page"`
} }
Result struct {
Pagination *Pagination `json:"pagination,omitempty"`
Columns []string `json:"columns"`
Rows []Row `json:"rows"`
Stats *ResultStats `json:"stats,omitempty"`
}
ResultStats struct {
ColumnsCount int `json:"columns_count"`
RowsCount int `json:"rows_count"`
RowsAffected int64 `json:"rows_affected"`
QueryStartTime time.Time `json:"query_start_time"`
QueryFinishTime time.Time `json:"query_finish_time"`
QueryDuration int64 `json:"query_duration_ms"`
}
Object struct {
OID string `json:"oid"`
Name string `json:"name"`
}
Objects struct {
Tables []Object `json:"table"`
Views []Object `json:"view"`
MaterializedViews []Object `json:"materialized_view"`
Functions []Object `json:"function"`
Sequences []Object `json:"sequence"`
}
)
// Due to big int number limitations in javascript, numbers should be encoded // Due to big int number limitations in javascript, numbers should be encoded
// as strings so they could be properly loaded on the frontend. // as strings so they could be properly loaded on the frontend.
func (res *Result) PrepareBigints() { func (res *Result) PostProcess() {
for i, row := range res.Rows { for i, row := range res.Rows {
for j, col := range row { for j, col := range row {
if col == nil { if col == nil {
continue continue
} }
switch reflect.TypeOf(col).Kind() { switch val := col.(type) {
case reflect.Int64: case int64:
val := col.(int64)
if val < -9007199254740991 || val > 9007199254740991 { if val < -9007199254740991 || val > 9007199254740991 {
res.Rows[i][j] = strconv.FormatInt(col.(int64), 10) res.Rows[i][j] = strconv.FormatInt(col.(int64), 10)
} }
case reflect.Float64: case float64:
val := col.(float64) // json.Marshal panics when dealing with NaN/Inf values
// issue: https://github.com/golang/go/issues/25721
if math.IsNaN(val) {
res.Rows[i][j] = nil
break
}
if val < -999999999999999 || val > 999999999999999 { if val < -999999999999999 || val > 999999999999999 {
res.Rows[i][j] = strconv.FormatFloat(val, 'e', -1, 64) res.Rows[i][j] = strconv.FormatFloat(val, 'e', -1, 64)
} }
case string:
if hasBinary(val, 8) && BinaryCodec != CodecNone {
res.Rows[i][j] = encodeBinaryData([]byte(val), BinaryCodec)
}
case time.Time:
// RFC 3339 is clear that years are 4 digits exactly.
// See golang.org/issue/4556#c15 for more discussion.
if val.Year() < 0 || val.Year() >= 10000 {
res.Rows[i][j] = "ERR: INVALID_DATE"
} else {
res.Rows[i][j] = val
}
} }
} }
} }
} }
func (res *Result) Format() []map[string]interface{} { 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{}) item := make(map[string]interface{})
for i, c := range res.Columns { for i, c := range res.Columns {
item[c] = row[i] item[c] = row[i]
} }
items = append(items, item) items[rowIdx] = item
} }
return items return items
@@ -77,26 +132,25 @@ func (res *Result) CSV() []byte {
buff := &bytes.Buffer{} buff := &bytes.Buffer{}
writer := csv.NewWriter(buff) writer := csv.NewWriter(buff)
writer.Write(res.Columns) if err := writer.Write(res.Columns); err != nil {
log.Printf("result csv write error: %v\n", err)
}
for _, row := range res.Rows { for _, row := range res.Rows {
record := make([]string, len(res.Columns)) record := make([]string, len(res.Columns))
for i, item := range row { for i, item := range row {
if item != nil { switch v := item.(type) {
switch v := item.(type) { case time.Time:
case time.Time: record[i] = v.Format("2006-01-02 15:04:05")
record[i] = v.Format("2006-01-02 15:04:05") case nil:
default:
record[i] = fmt.Sprintf("%v", item)
}
} else {
record[i] = "" record[i] = ""
default:
record[i] = fmt.Sprintf("%v", item)
} }
} }
err := writer.Write(record) err := writer.Write(record)
if err != nil { if err != nil {
fmt.Println(err) fmt.Println(err)
break break
@@ -108,7 +162,14 @@ func (res *Result) CSV() []byte {
} }
func (res *Result) JSON() []byte { func (res *Result) JSON() []byte {
data, _ := json.Marshal(res.Format()) var data []byte
if command.Opts.DisablePrettyJSON {
data, _ = json.Marshal(res.Format())
} else {
data, _ = json.MarshalIndent(res.Format(), "", " ")
}
return data return data
} }
@@ -116,28 +177,34 @@ func ObjectsFromResult(res *Result) map[string]*Objects {
objects := map[string]*Objects{} objects := map[string]*Objects{}
for _, row := range res.Rows { for _, row := range res.Rows {
schema := row[0].(string) oid := row[0].(string)
name := row[1].(string) schema := row[1].(string)
object_type := row[2].(string) name := row[2].(string)
objectType := row[3].(string)
if objects[schema] == nil { if objects[schema] == nil {
objects[schema] = &Objects{ objects[schema] = &Objects{
Tables: []string{}, Tables: []Object{},
Views: []string{}, Views: []Object{},
MaterializedViews: []string{}, MaterializedViews: []Object{},
Sequences: []string{}, Functions: []Object{},
Sequences: []Object{},
} }
} }
switch object_type { obj := Object{OID: oid, Name: name}
case "table":
objects[schema].Tables = append(objects[schema].Tables, name) switch objectType {
case "view": case ObjTypeTable:
objects[schema].Views = append(objects[schema].Views, name) objects[schema].Tables = append(objects[schema].Tables, obj)
case "materialized_view": case ObjTypeView:
objects[schema].MaterializedViews = append(objects[schema].MaterializedViews, name) objects[schema].Views = append(objects[schema].Views, obj)
case "sequence": case ObjTypeMaterializedView:
objects[schema].Sequences = append(objects[schema].Sequences, name) objects[schema].MaterializedViews = append(objects[schema].MaterializedViews, obj)
case ObjTypeFunction:
objects[schema].Functions = append(objects[schema].Functions, obj)
case ObjTypeSequence:
objects[schema].Sequences = append(objects[schema].Sequences, obj)
} }
} }
+97 -31
View File
@@ -2,53 +2,78 @@ package client
import ( import (
"encoding/json" "encoding/json"
"strings"
"testing" "testing"
"time"
"github.com/sosedoff/pgweb/pkg/command"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func Test_PrepareBigints(t *testing.T) { func TestPostProcess(t *testing.T) {
t.Run("large numbers", func(t *testing.T) {
result := Result{
Columns: []string{"value"},
Rows: []Row{
{int(1234)},
{int64(9223372036854775807)},
{int64(-9223372036854775808)},
{float64(9223372036854775808.9223372036854775808)},
{float64(999999999999999.9)},
},
}
result.PostProcess()
assert.Equal(t, 1234, result.Rows[0][0])
assert.Equal(t, "9223372036854775807", result.Rows[1][0])
assert.Equal(t, "-9223372036854775808", result.Rows[2][0])
assert.Equal(t, "9.223372036854776e+18", result.Rows[3][0])
assert.Equal(t, "9.999999999999999e+14", result.Rows[4][0])
})
t.Run("binary encoding", func(t *testing.T) {
result := Result{
Columns: []string{"data"},
Rows: []Row{
{"text value"},
{"text with symbols !@#$%"},
{string([]byte{10, 11, 12, 13})},
},
}
result.PostProcess()
assert.Equal(t, "text value", result.Rows[0][0])
assert.Equal(t, "text with symbols !@#$%", result.Rows[1][0])
assert.Equal(t, "CgsMDQ==", result.Rows[2][0])
})
}
func TestCSV(t *testing.T) {
result := Result{ result := Result{
Columns: []string{"value"}, Columns: []string{"id", "name", "email", "extra"},
Rows: []Row{ Rows: []Row{
Row{int(1234)}, {1, "John", "john@example.com", "data"},
Row{int64(9223372036854775807)}, {2, "Bob", "bob@example.com", nil},
Row{int64(-9223372036854775808)},
Row{float64(9223372036854775808.9223372036854775808)},
Row{float64(999999999999999.9)},
}, },
} }
result.PrepareBigints() expected := strings.Join([]string{
"id,name,email,extra",
"1,John,john@example.com,data",
"2,Bob,bob@example.com,",
}, "\n") + "\n"
assert.Equal(t, 1234, result.Rows[0][0]) assert.Equal(t, expected, string(result.CSV()))
assert.Equal(t, "9223372036854775807", result.Rows[1][0])
assert.Equal(t, "-9223372036854775808", result.Rows[2][0])
assert.Equal(t, "9.223372036854776e+18", result.Rows[3][0])
assert.Equal(t, "9.999999999999999e+14", result.Rows[4][0])
} }
func Test_CSV(t *testing.T) { func TestJSON(t *testing.T) {
result := Result{ result := Result{
Columns: []string{"id", "name", "email"}, Columns: []string{"id", "name", "email"},
Rows: []Row{ Rows: []Row{
Row{1, "John", "john@example.com"}, {1, "John", "john@example.com"},
Row{2, "Bob", "bob@example.com"}, {2, "Bob", "bob@example.com"},
},
}
expected := "id,name,email\n1,John,john@example.com\n2,Bob,bob@example.com\n"
output := string(result.CSV())
assert.Equal(t, expected, output)
}
func Test_JSON(t *testing.T) {
result := Result{
Columns: []string{"id", "name", "email"},
Rows: []Row{
Row{1, "John", "john@example.com"},
Row{2, "Bob", "bob@example.com"},
}, },
} }
@@ -70,4 +95,45 @@ func Test_JSON(t *testing.T) {
assert.NoError(t, json.Unmarshal(result.JSON(), &obj)) assert.NoError(t, json.Unmarshal(result.JSON(), &obj))
assert.Equal(t, 2, len(obj)) assert.Equal(t, 2, len(obj))
assert.Equal(t, expected, obj) assert.Equal(t, expected, obj)
t.Run("invalid time", func(t *testing.T) {
loc, err := time.LoadLocation("UTC")
if err != nil {
panic(err)
}
command.Opts.DisablePrettyJSON = true
defer func() {
command.Opts.DisablePrettyJSON = false
}()
result := Result{
Columns: []string{"value"},
Rows: []Row{
{time.Unix(1640995200, 0).In(loc)},
{time.Unix(222539616000, 0).In(loc)},
{time.Unix(254096611200, 0).In(loc)},
},
}
result.PostProcess()
assert.Equal(t, `[{"value":"2022-01-01T00:00:00Z"},{"value":"9022-01-01T00:00:00Z"},{"value":"ERR: INVALID_DATE"}]`, string(result.JSON()))
})
}
func TestResultFormat(t *testing.T) {
result := Result{
Columns: []string{"col1", "col2", "col3", "col4"},
Rows: []Row{
{"1", "2", "3", nil},
{"4", "5", "6", nil},
},
}
expected := []map[string]interface{}{
{"col1": "1", "col2": "2", "col3": "3", "col4": nil},
{"col1": "4", "col2": "5", "col3": "6", "col4": nil},
}
assert.Equal(t, expected, result.Format())
} }
+67 -22
View File
@@ -1,16 +1,19 @@
package client package client
import ( import (
"errors"
"fmt" "fmt"
"io" "io"
"io/ioutil"
"log" "log"
"net" "net"
"net/url" "net/url"
"os" "os"
"path/filepath"
"strings" "strings"
"sync" "sync"
"time"
"github.com/ScaleFT/sshkeys"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
"github.com/sosedoff/pgweb/pkg/connection" "github.com/sosedoff/pgweb/pkg/connection"
@@ -18,10 +21,11 @@ import (
) )
const ( const (
PORT_START = 29168 portStart = 29168
PORT_LIMIT = 500 portLimit = 500
) )
// Tunnel represents the connection between local and remote server
type Tunnel struct { type Tunnel struct {
TargetHost string TargetHost string
TargetPort string TargetPort string
@@ -32,8 +36,16 @@ type Tunnel struct {
Listener *net.TCPListener Listener *net.TCPListener
} }
func privateKeyPath() string { func defaultKeyPath() string {
return os.Getenv("HOME") + "/.ssh/id_rsa" return filepath.Join(os.Getenv("HOME"), ".ssh/id_rsa")
}
func expandKeyPath(path string) string {
home := os.Getenv("HOME")
if home == "" {
return path
}
return strings.Replace(path, "~", home, 1)
} }
func fileExists(path string) bool { func fileExists(path string) bool {
@@ -41,31 +53,60 @@ func fileExists(path string) bool {
return err == nil return err == nil
} }
func parsePrivateKey(keyPath string) (ssh.Signer, error) { func parsePrivateKey(keyPath string, keyPass string) (ssh.Signer, error) {
buff, err := ioutil.ReadFile(keyPath) buff, err := os.ReadFile(keyPath)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return ssh.ParsePrivateKey(buff) signer, err := ssh.ParsePrivateKey(buff)
if _, ok := err.(*ssh.PassphraseMissingError); ok {
if keyPass == "" {
return nil, errors.New("ssh key password is not provided")
}
return sshkeys.ParseEncryptedPrivateKey(buff, []byte(keyPass))
}
return signer, err
} }
func makeConfig(info *shared.SSHInfo) (*ssh.ClientConfig, error) { func makeConfig(info *shared.SSHInfo) (*ssh.ClientConfig, error) {
methods := []ssh.AuthMethod{} methods := []ssh.AuthMethod{}
keyPath := privateKeyPath() // Try to use user-provided key, fallback to system default key
if fileExists(keyPath) { keyPath := info.Key
key, err := parsePrivateKey(keyPath) if keyPath == "" {
if err != nil { keyPath = defaultKeyPath()
return nil, err } else {
} keyPath = expandKeyPath(keyPath)
methods = append(methods, ssh.PublicKeys(key))
} }
methods = append(methods, ssh.Password(info.Password)) if !fileExists(keyPath) {
return nil, fmt.Errorf("ssh public key not found at path %q", keyPath)
}
return &ssh.ClientConfig{User: info.User, Auth: methods}, nil // Append public key authentication method
key, err := parsePrivateKey(keyPath, info.KeyPassword)
if err != nil {
return nil, err
}
methods = append(methods, ssh.PublicKeys(key))
// Append password authentication method
if info.Password != "" {
methods = append(methods, ssh.Password(info.Password))
}
cfg := &ssh.ClientConfig{
User: info.User,
Auth: methods,
Timeout: time.Second * 10,
HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
return nil
},
}
return cfg, nil
} }
func (tunnel *Tunnel) sshEndpoint() string { func (tunnel *Tunnel) sshEndpoint() string {
@@ -89,16 +130,17 @@ func (tunnel *Tunnel) handleConnection(local net.Conn) {
return return
} }
wg := sync.WaitGroup{} wg := &sync.WaitGroup{}
wg.Add(2) wg.Add(2)
go tunnel.copy(&wg, local, remote) go tunnel.copy(wg, local, remote)
go tunnel.copy(&wg, remote, local) go tunnel.copy(wg, remote, local)
wg.Wait() wg.Wait()
local.Close() local.Close()
} }
// Close closes the tunnel connection
func (tunnel *Tunnel) Close() { func (tunnel *Tunnel) Close() {
if tunnel.Client != nil { if tunnel.Client != nil {
tunnel.Client.Close() tunnel.Client.Close()
@@ -109,6 +151,7 @@ func (tunnel *Tunnel) Close() {
} }
} }
// Configure establishes the tunnel between localhost and remote machine
func (tunnel *Tunnel) Configure() error { func (tunnel *Tunnel) Configure() error {
config, err := makeConfig(tunnel.SSHInfo) config, err := makeConfig(tunnel.SSHInfo)
if err != nil { if err != nil {
@@ -131,6 +174,7 @@ func (tunnel *Tunnel) Configure() error {
return nil return nil
} }
// Start starts the connection handler loop
func (tunnel *Tunnel) Start() { func (tunnel *Tunnel) Start() {
defer tunnel.Close() defer tunnel.Close()
@@ -144,13 +188,14 @@ func (tunnel *Tunnel) Start() {
} }
} }
// NewTunnel instantiates a new tunnel struct from given ssh info
func NewTunnel(sshInfo *shared.SSHInfo, dbUrl string) (*Tunnel, error) { func NewTunnel(sshInfo *shared.SSHInfo, dbUrl string) (*Tunnel, error) {
uri, err := url.Parse(dbUrl) uri, err := url.Parse(dbUrl)
if err != nil { if err != nil {
return nil, err return nil, err
} }
listenPort, err := connection.AvailablePort(PORT_START, PORT_LIMIT) listenPort, err := connection.FindAvailablePort(portStart, portLimit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+101
View File
@@ -0,0 +1,101 @@
package client
import (
"fmt"
"regexp"
"strings"
)
var (
// List of keywords that are not allowed in read-only mode
reRestrictedKeywords = regexp.MustCompile(`(?mi)\s?(CREATE|INSERT|UPDATE|DROP|DELETE|TRUNCATE|GRANT|OPEN|IMPORT|COPY)\s`)
// Comment regular expressions
reSlashComment = regexp.MustCompile(`(?m)/\*.+\*/`)
reDashComment = regexp.MustCompile(`(?m)--.+`)
// Postgres version signature
postgresSignature = regexp.MustCompile(`(?i)postgresql ([\d\.]+)\s?`)
postgresDumpSignature = regexp.MustCompile(`\s([\d\.]+)\s?`)
postgresType = "PostgreSQL"
// Cockroach version signature
cockroachSignature = regexp.MustCompile(`(?i)cockroachdb ccl v([\d\.]+)\s?`)
cockroachType = "CockroachDB"
)
// Get major and minor version components
// Example: 10.2.3.1 -> 10.2
func getMajorMinorVersion(str string) (major int, minor int) {
chunks := strings.Split(str, ".")
fmt.Sscanf(chunks[0], "%d", &major) //nolint
if len(chunks) > 1 {
fmt.Sscanf(chunks[1], "%d", &minor) //nolint
}
return
}
// Get short version from the string
// Example: 10.2.3.1 -> 10.2
func getMajorMinorVersionString(str string) string {
major, minor := getMajorMinorVersion(str)
return fmt.Sprintf("%d.%d", major, minor)
}
func detectServerTypeAndVersion(version string) (bool, string, string) {
version = strings.TrimSpace(version)
// Detect postgresql
matches := postgresSignature.FindAllStringSubmatch(version, 1)
if len(matches) > 0 {
return true, postgresType, matches[0][1]
}
// Detect cockroachdb
matches = cockroachSignature.FindAllStringSubmatch(version, 1)
if len(matches) > 0 {
return true, cockroachType, matches[0][1]
}
return false, "", ""
}
// detectDumpVersion parses out version from `pg_dump -V` command.
func detectDumpVersion(version string) (bool, string) {
matches := postgresDumpSignature.FindAllStringSubmatch(version, 1)
if len(matches) > 0 {
return true, matches[0][1]
}
return false, ""
}
func checkVersionRequirement(client, server string) bool {
clientMajor, clientMinor := getMajorMinorVersion(client)
serverMajor, serverMinor := getMajorMinorVersion(server)
if serverMajor < 10 {
return clientMajor >= serverMajor && clientMinor >= serverMinor
}
return clientMajor >= serverMajor
}
// containsRestrictedKeywords returns true if given keyword is not allowed in read-only mode
func containsRestrictedKeywords(str string) bool {
str = reSlashComment.ReplaceAllString(str, "")
str = reDashComment.ReplaceAllString(str, "")
return reRestrictedKeywords.MatchString(str)
}
func hasBinary(data string, checkLen int) bool {
for idx, chr := range data {
if int(chr) < 32 || int(chr) > 126 {
return true
}
if idx >= checkLen {
break
}
}
return false
}
+116
View File
@@ -0,0 +1,116 @@
package client
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestDetectServerType(t *testing.T) {
examples := []struct {
input string
match bool
serverType string
version string
}{
{input: "",
match: false,
serverType: "",
version: "",
},
{
input: " postgresql 15 ",
match: true,
serverType: postgresType,
version: "15",
},
{
input: "PostgreSQL 14.5 (Homebrew) on aarch64-apple-darwin21.6.0",
match: true,
serverType: postgresType,
version: "14.5",
},
{
input: "PostgreSQL 11.16, compiled by Visual C++ build 1800, 64-bit",
match: true,
serverType: postgresType,
version: "11.16",
},
}
for _, ex := range examples {
t.Run("input:"+ex.input, func(t *testing.T) {
match, stype, version := detectServerTypeAndVersion(ex.input)
assert.Equal(t, ex.match, match)
assert.Equal(t, ex.serverType, stype)
assert.Equal(t, ex.version, version)
})
}
}
func TestDetectDumpVersion(t *testing.T) {
examples := []struct {
input string
match bool
version string
}{
{"", false, ""},
{"pg_dump (PostgreSQL) 9.6", true, "9.6"},
{"pg_dump 10", true, "10"},
{"pg_dump (PostgreSQL) 14.5 (Homebrew)", true, "14.5"},
}
for _, ex := range examples {
t.Run("input:"+ex.input, func(t *testing.T) {
match, version := detectDumpVersion(ex.input)
assert.Equal(t, ex.match, match)
assert.Equal(t, ex.version, version)
})
}
}
func TestGetMajorMinorVersion(t *testing.T) {
examples := []struct {
input string
major int
minor int
}{
{"", 0, 0},
{" ", 0, 0},
{"0", 0, 0},
{"9.6", 9, 6},
{"9.6.1.1", 9, 6},
{"10", 10, 0},
{"10.1 ", 10, 1},
}
for _, ex := range examples {
t.Run(ex.input, func(t *testing.T) {
major, minor := getMajorMinorVersion(ex.input)
assert.Equal(t, ex.major, major)
assert.Equal(t, ex.minor, minor)
})
}
}
func TestCheckVersionRequirement(t *testing.T) {
examples := []struct {
client string
server string
result bool
}{
{"", "", true},
{"0", "0", true},
{"9.6", "9.7", false},
{"9.6.10", "9.6.25", true},
{"10.0", "10.1", true},
{"10.5", "10.1", true},
{"14.5", "15.1", false},
}
for _, ex := range examples {
assert.Equal(t, ex.result, checkVersionRequirement(ex.client, ex.server))
}
}
+226 -52
View File
@@ -1,72 +1,246 @@
package command package command
import ( import (
"errors"
"fmt" "fmt"
"os" "os"
"os/user"
"path/filepath"
"strings" "strings"
"github.com/jackc/pgpassfile"
"github.com/jessevdk/go-flags" "github.com/jessevdk/go-flags"
"github.com/mitchellh/go-homedir"
"github.com/sirupsen/logrus"
)
const (
// Prefix to use for all pgweb env vars, ie PGWEB_HOST, PGWEB_PORT, etc
envVarPrefix = "PGWEB_"
) )
type Options struct { type Options struct {
Version bool `short:"v" long:"version" description:"Print version"` Version bool `short:"v" long:"version" description:"Print version"`
Debug bool `short:"d" long:"debug" description:"Enable debugging mode" default:"false"` Debug bool `short:"d" long:"debug" description:"Enable debugging mode"`
Url string `long:"url" description:"Database connection string"` LogLevel string `long:"log-level" description:"Logging level" default:"info"`
Host string `long:"host" description:"Server hostname or IP"` LogFormat string `long:"log-format" description:"Logging output format" default:"text"`
Port int `long:"port" description:"Server port" default:"5432"` LogForwardedUser bool `long:"log-forwarded-user" description:"Log user information available in X-Forwarded-User/Email headers"`
User string `long:"user" description:"Database user"` URL string `long:"url" description:"Database connection string"`
Pass string `long:"pass" description:"Password for user"` Host string `long:"host" description:"Server hostname or IP" default:"localhost"`
DbName string `long:"db" description:"Database name"` Port int `long:"port" description:"Server port" default:"5432"`
Ssl string `long:"ssl" description:"SSL option"` User string `long:"user" description:"Database user"`
HttpHost string `long:"bind" description:"HTTP server host" default:"localhost"` Pass string `long:"pass" description:"Password for user"`
HttpPort uint `long:"listen" description:"HTTP server listen port" default:"8081"` Passfile string `long:"passfile" description:"Local passwords file location"`
AuthUser string `long:"auth-user" description:"HTTP basic auth user"` DbName string `long:"db" description:"Database name"`
AuthPass string `long:"auth-pass" description:"HTTP basic auth password"` SSLMode string `long:"ssl" description:"SSL mode"`
SkipOpen bool `short:"s" long:"skip-open" description:"Skip browser open on start"` SSLRootCert string `long:"ssl-rootcert" description:"SSL certificate authority file"`
Sessions bool `long:"sessions" description:"Enable multiple database sessions" default:"false"` SSLCert string `long:"ssl-cert" description:"SSL client certificate file"`
Prefix string `long:"prefix" description:"Add a url prefix"` SSLKey string `long:"ssl-key" description:"SSL client certificate key file"`
ReadOnly bool `long:"readonly" description:"Run database connection in readonly mode"` OpenTimeout int `long:"open-timeout" description:"Maximum wait time for connection, in seconds" default:"30"`
LockSession bool `long:"lock-session" description:"Lock session to a single database connection" default:"false"` RetryDelay uint `long:"open-retry-delay" description:"Number of seconds to wait before retrying the connection" default:"3"`
Bookmark string `short:"b" long:"bookmark" description:"Bookmark to use for connection. Bookmark files are stored under $HOME/.pgweb/bookmarks/*.toml" default:""` RetryCount uint `long:"open-retry" description:"Number of times to retry establishing connection" default:"0"`
HTTPAddr string `long:"address" description:"anyhttp address. See https://pkg.go.dev/go.balki.me/anyhttp#readme-address-syntax" default:""`
HTTPHost string `long:"bind" description:"HTTP server host" default:"localhost"`
HTTPPort uint `long:"listen" description:"HTTP server listen port" default:"8081"`
AuthUser string `long:"auth-user" description:"HTTP basic auth user"`
AuthPass string `long:"auth-pass" description:"HTTP basic auth password"`
SkipOpen bool `short:"s" long:"skip-open" description:"Skip browser open on start"`
Sessions bool `long:"sessions" description:"Enable multiple database sessions"`
Prefix string `long:"prefix" description:"Add a url prefix"`
ReadOnly bool `long:"readonly" description:"Run database connection in readonly mode"`
LockSession bool `long:"lock-session" description:"Lock session to a single database connection"`
Bookmark string `short:"b" long:"bookmark" description:"Bookmark to use for connection. Bookmark files are stored under $HOME/.pgweb/bookmarks/*.toml" default:""`
BookmarksDir string `long:"bookmarks-dir" description:"Overrides default directory for bookmark files to search" default:""`
BookmarksOnly bool `long:"bookmarks-only" description:"Allow only connections from bookmarks"`
QueriesDir string `long:"queries-dir" description:"Overrides default directory for local queries"`
DisablePrettyJSON bool `long:"no-pretty-json" description:"Disable JSON formatting feature for result export"`
DisableSSH bool `long:"no-ssh" description:"Disable database connections via SSH"`
ConnectBackend string `long:"connect-backend" description:"Enable database authentication through a third party backend"`
ConnectToken string `long:"connect-token" description:"Authentication token for the third-party connect backend"`
ConnectHeaders string `long:"connect-headers" description:"List of headers to pass to the connect backend"`
DisableConnectionIdleTimeout bool `long:"no-idle-timeout" description:"Disable connection idle timeout"`
ConnectionIdleTimeout int `long:"idle-timeout" description:"Set connection idle timeout in minutes" default:"180"`
QueryTimeout uint `long:"query-timeout" description:"Set global query execution timeout in seconds" default:"300"`
Cors bool `long:"cors" description:"Enable Cross-Origin Resource Sharing (CORS)"`
CorsOrigin string `long:"cors-origin" description:"Allowed CORS origins" default:"*"`
BinaryCodec string `long:"binary-codec" description:"Codec for binary data serialization, one of 'none', 'hex', 'base58', 'base64'" default:"none"`
MetricsEnabled bool `long:"metrics" description:"Enable Prometheus metrics endpoint"`
MetricsPath string `long:"metrics-path" description:"Path prefix for Prometheus metrics endpoint" default:"/metrics"`
MetricsAddr string `long:"metrics-addr" description:"Listen host and port for Prometheus metrics server"`
} }
var Opts Options var Opts Options
func ParseOptions() error { // ParseOptions returns a new options struct from the input arguments
_, err := flags.ParseArgs(&Opts, os.Args) func ParseOptions(args []string) (Options, error) {
var opts = Options{}
_, err := flags.ParseArgs(&opts, args)
if err != nil {
return opts, err
}
_, err = logrus.ParseLevel(opts.LogLevel)
if err != nil {
return opts, err
}
if opts.URL == "" {
opts.URL = getPrefixedEnvVar("DATABASE_URL")
}
if opts.Prefix == "" {
opts.Prefix = getPrefixedEnvVar("URL_PREFIX")
}
if opts.Passfile == "" {
passfile := os.Getenv("PGPASSFILE")
if passfile == "" {
passfile = filepath.Join(os.Getenv("HOME"), ".pgpass")
}
_, err := os.Stat(passfile)
if err == nil {
_, err = pgpassfile.ReadPassfile(passfile)
if err == nil {
opts.Passfile = passfile
} else {
fmt.Printf("[WARN] Pgpass file unreadable: %s\n", err)
}
}
}
// Handle edge case where pgweb is started with a default host `localhost` and no user.
// When user is not set the `lib/pq` connection will fail and cause pgweb's termination.
if (opts.Host == "localhost" || opts.Host == "127.0.0.1") && opts.User == "" {
if username := getCurrentUser(); username != "" {
opts.User = username
} else {
opts.Host = ""
}
}
if getPrefixedEnvVar("BOOKMARKS_ONLY") != "" {
opts.BookmarksOnly = true
}
if getPrefixedEnvVar("SESSIONS") != "" {
opts.Sessions = true
}
if getPrefixedEnvVar("LOCK_SESSION") != "" {
opts.LockSession = true
opts.Sessions = false
}
if opts.Sessions || opts.ConnectBackend != "" {
opts.Bookmark = ""
opts.URL = ""
opts.Host = ""
opts.User = ""
opts.Pass = ""
opts.DbName = ""
opts.SSLMode = ""
}
if opts.Prefix != "" && !strings.HasSuffix(opts.Prefix, "/") {
opts.Prefix = opts.Prefix + "/"
}
if opts.AuthUser == "" {
opts.AuthUser = getPrefixedEnvVar("AUTH_USER")
}
if opts.AuthPass == "" {
opts.AuthPass = getPrefixedEnvVar("AUTH_PASS")
}
if opts.ConnectBackend != "" {
if !opts.Sessions {
return opts, errors.New("--sessions flag must be set")
}
if opts.ConnectToken == "" {
return opts, errors.New("--connect-token flag must be set")
}
} else {
if opts.ConnectToken != "" || opts.ConnectHeaders != "" {
return opts, errors.New("--connect-backend flag must be set")
}
}
if opts.BookmarksOnly {
if opts.URL != "" {
return opts, errors.New("--url not supported in bookmarks-only mode")
}
if opts.Host != "" && opts.Host != "localhost" {
return opts, errors.New("--host not supported in bookmarks-only mode")
}
if opts.ConnectBackend != "" {
return opts, errors.New("--connect-backend not supported in bookmarks-only mode")
}
}
homePath, err := homedir.Dir()
if err != nil {
fmt.Fprintf(os.Stderr, "[WARN] can't detect home dir: %v", err)
homePath = os.Getenv("HOME")
}
if homePath != "" {
if opts.BookmarksDir == "" {
opts.BookmarksDir = filepath.Join(homePath, ".pgweb/bookmarks")
}
if opts.QueriesDir == "" {
opts.QueriesDir = filepath.Join(homePath, ".pgweb/queries")
}
}
return opts, nil
}
// SetDefaultOptions parses and assigns the options
func SetDefaultOptions() error {
opts, err := ParseOptions([]string{})
if err != nil { if err != nil {
return err return err
} }
Opts = opts
if Opts.Url == "" {
Opts.Url = os.Getenv("DATABASE_URL")
}
if os.Getenv("SESSIONS") != "" {
Opts.Sessions = true
}
if os.Getenv("LOCK_SESSION") != "" {
Opts.LockSession = true
Opts.Sessions = false
}
// When session is locked, connection UI is not displayed.
if Opts.LockSession && Opts.Url == "" {
return fmt.Errorf("Please provide connection url")
}
if Opts.Prefix != "" && !strings.Contains(Opts.Prefix, "/") {
Opts.Prefix = Opts.Prefix + "/"
}
if Opts.AuthUser == "" && os.Getenv("AUTH_USER") != "" {
Opts.AuthUser = os.Getenv("AUTH_USER")
}
if Opts.AuthPass == "" && os.Getenv("AUTH_PASS") != "" {
Opts.AuthPass = os.Getenv("AUTH_PASS")
}
return nil return nil
} }
// getCurrentUser returns a current user name
func getCurrentUser() string {
u, _ := user.Current()
if u != nil {
return u.Username
}
return os.Getenv("USER")
}
// getPrefixedEnvVar returns env var with prefix, or falls back to unprefixed one
func getPrefixedEnvVar(name string) string {
val := os.Getenv(envVarPrefix + name)
if val == "" {
val = os.Getenv(name)
if val != "" {
fmt.Printf("[DEPRECATION] Usage of %s env var is deprecated, please use PGWEB_%s variable instead\n", name, name)
}
}
return val
}
// AvailableEnvVars returns list of supported env vars.
//
// TODO: These should probably be embedded into flag parsing logic so we dont have
// to maintain the list manually.
func AvailableEnvVars() string {
return strings.Join([]string{
" " + envVarPrefix + "DATABASE_URL Database connection string",
" " + envVarPrefix + "URL_PREFIX HTTP server path prefix",
" " + envVarPrefix + "SESSIONS Enable multiple database sessions",
" " + envVarPrefix + "LOCK_SESSION Lock session to a single database connection",
" " + envVarPrefix + "AUTH_USER HTTP basic auth username",
" " + envVarPrefix + "AUTH_PASS HTTP basic auth password",
}, "\n")
}
+86 -27
View File
@@ -2,37 +2,96 @@ package command
import ( import (
"os" "os"
"path/filepath"
"testing" "testing"
"github.com/mitchellh/go-homedir"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func Test_Options(t *testing.T) { func TestParseOptions(t *testing.T) {
err := ParseOptions() var hdir string
if d, err := homedir.Dir(); err == nil {
hdir = d
}
assert.NoError(t, err) t.Run("defaults", func(t *testing.T) {
assert.Equal(t, false, Opts.Sessions) opts, err := ParseOptions([]string{})
assert.Equal(t, "", Opts.Prefix) assert.NoError(t, err)
} assert.Equal(t, false, opts.Sessions)
assert.Equal(t, "", opts.Prefix)
func Test_SessionsOption(t *testing.T) { assert.Equal(t, "", opts.ConnectToken)
oldargs := os.Args assert.Equal(t, "", opts.ConnectHeaders)
defer func() { os.Args = oldargs }() assert.Equal(t, false, opts.DisableSSH)
assert.Equal(t, false, opts.DisablePrettyJSON)
os.Args = []string{"--sessions", "1"} assert.Equal(t, false, opts.DisableConnectionIdleTimeout)
assert.NoError(t, ParseOptions()) assert.Equal(t, 180, opts.ConnectionIdleTimeout)
assert.Equal(t, true, Opts.Sessions) assert.Equal(t, false, opts.Cors)
} assert.Equal(t, "*", opts.CorsOrigin)
assert.Equal(t, "", opts.Passfile)
func Test_PrefixOption(t *testing.T) { assert.Equal(t, filepath.Join(hdir, ".pgweb/bookmarks"), opts.BookmarksDir)
oldargs := os.Args })
defer func() { os.Args = oldargs }()
t.Run("sessions", func(t *testing.T) {
os.Args = []string{"--prefix", "pgweb"} opts, err := ParseOptions([]string{"--sessions", "1"})
assert.NoError(t, ParseOptions()) assert.NoError(t, err)
assert.Equal(t, "pgweb/", Opts.Prefix) assert.Equal(t, true, opts.Sessions)
})
os.Args = []string{"--prefix", "pgweb/"}
assert.NoError(t, ParseOptions()) t.Run("url prefix", func(t *testing.T) {
assert.Equal(t, "pgweb/", Opts.Prefix) opts, err := ParseOptions([]string{"--prefix", "pgweb"})
assert.NoError(t, err)
assert.Equal(t, "pgweb/", opts.Prefix)
opts, err = ParseOptions([]string{"--prefix", "pgweb/"})
assert.NoError(t, err)
assert.Equal(t, "pgweb/", opts.Prefix)
})
t.Run("connect backend", func(t *testing.T) {
_, err := ParseOptions([]string{"--connect-backend", "test"})
assert.EqualError(t, err, "--sessions flag must be set")
_, err = ParseOptions([]string{"--connect-backend", "test", "--sessions"})
assert.EqualError(t, err, "--connect-token flag must be set")
_, err = ParseOptions([]string{"--connect-backend", "test", "--sessions", "--connect-token", "token"})
assert.NoError(t, err)
})
t.Run("passfile", func(t *testing.T) {
defer os.Unsetenv("PGPASSFILE")
// File does not exist
os.Setenv("PGPASSFILE", "/tmp/foo")
opts, err := ParseOptions([]string{})
assert.NoError(t, err)
assert.Equal(t, "", opts.Passfile)
// File exists and valid
os.Setenv("PGPASSFILE", "../../data/passfile")
opts, err = ParseOptions([]string{})
assert.NoError(t, err)
assert.Equal(t, "../../data/passfile", opts.Passfile)
// Set via flag
os.Unsetenv("PGPASSFILE")
opts, err = ParseOptions([]string{"--passfile", "../../data/passfile"})
assert.NoError(t, err)
assert.Equal(t, "../../data/passfile", opts.Passfile)
})
t.Run("bookmarks only mode", func(t *testing.T) {
_, err := ParseOptions([]string{"--bookmarks-only"})
assert.NoError(t, err)
_, err = ParseOptions([]string{"--bookmarks-only", "--url", "test"})
assert.EqualError(t, err, "--url not supported in bookmarks-only mode")
_, err = ParseOptions([]string{"--bookmarks-only", "--host", "test", "--port", "5432"})
assert.EqualError(t, err, "--host not supported in bookmarks-only mode")
_, err = ParseOptions([]string{"--bookmarks-only", "--connect-backend", "test", "--sessions", "--connect-token", "token", "--url", "127.0.0.2"})
assert.EqualError(t, err, "--connect-backend not supported in bookmarks-only mode")
})
} }
+57 -1
View File
@@ -1,8 +1,64 @@
package command package command
const VERSION = "0.9.6" import (
"fmt"
"runtime"
"strings"
)
const (
// Version is the current Pgweb application version
Version = "0.17.0"
)
var ( var (
// GitCommit contains the Git commit SHA for the binary
GitCommit string GitCommit string
// BuildTime contains the binary build time
BuildTime string BuildTime string
// BuildArch contains the OS architecture of the binary
BuildArch string = fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)
// GoVersion contains the build time Go version
GoVersion string
// Info contains all version information
Info VersionInfo
) )
type VersionInfo struct {
Version string `json:"version"`
GitCommit string `json:"git_sha"`
BuildTime string `json:"build_time"`
BuildArch string `json:"build_arch"`
GoVersion string `json:"go_version"`
}
func init() {
Info.Version = Version
Info.GitCommit = GitCommit
Info.BuildTime = BuildTime
Info.BuildArch = BuildArch
Info.GoVersion = GoVersion
}
func VersionString() string {
chunks := []string{fmt.Sprintf("Pgweb v%s", Version)}
if GitCommit != "" {
chunks = append(chunks, fmt.Sprintf("(git: %s)", GitCommit))
}
if GoVersion != "" {
chunks = append(chunks, fmt.Sprintf("(go: %s)", GoVersion))
}
if BuildTime != "" {
chunks = append(chunks, fmt.Sprintf("(build time: %s)", BuildTime))
}
if BuildArch != "" {
chunks = append(chunks, fmt.Sprintf("(arch: %s)", BuildArch))
}
return strings.Join(chunks, " ")
}
+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
}
+173
View File
@@ -0,0 +1,173 @@
package connect
import (
"context"
"errors"
"net"
"net/http"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
)
func TestBackendFetchCredential(t *testing.T) {
examples := []struct {
name string
backend Backend
resourceName string
cred *Credential
headers http.Header
ctx func() (context.Context, context.CancelFunc)
err error
}{
{
name: "Bad auth token",
backend: Backend{Endpoint: "http://localhost:5555/unauthorized"},
err: errors.New("backend credential fetch received HTTP status code 401"),
},
{
name: "Backend timeout",
backend: Backend{Endpoint: "http://localhost:5555/timeout"},
ctx: func() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), time.Millisecond*100)
},
err: errors.New("unable to connect to the auth backend"),
},
{
name: "Empty response",
backend: Backend{Endpoint: "http://localhost:5555/empty-response"},
err: errors.New("connection string is required"),
},
{
name: "Missing header",
backend: Backend{Endpoint: "http://localhost:5555/pass-header"},
err: errors.New("backend credential fetch received HTTP status code 400"),
},
{
name: "Require header",
backend: Backend{
Endpoint: "http://localhost:5555/pass-header",
PassHeaders: []string{"x-foo"},
},
headers: http.Header{
"X-Foo": []string{"bar"},
},
cred: &Credential{DatabaseURL: "postgres://hostname/bar"},
},
{
name: "Success",
backend: Backend{Endpoint: "http://localhost:5555/success"},
cred: &Credential{DatabaseURL: "postgres://hostname/dbname"},
},
}
srvCtx, srvCancel := context.WithTimeout(context.Background(), time.Minute)
defer srvCancel()
startTestBackend(srvCtx, "localhost:5555")
for _, ex := range examples {
ex.backend.logger = logrus.StandardLogger()
t.Run(ex.name, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
if ex.ctx != nil {
ctx, cancel = ex.ctx()
}
defer cancel()
cred, err := ex.backend.FetchCredential(ctx, ex.resourceName, ex.headers)
assert.Equal(t, ex.err, err)
assert.Equal(t, ex.cred, cred)
})
}
}
func startTestBackend(ctx context.Context, listenAddr string) {
router := gin.New()
router.Use(func(c *gin.Context) {
if c.GetHeader("content-type") != "application/json" {
c.AbortWithStatus(http.StatusBadRequest)
}
})
router.POST("/unauthorized", func(c *gin.Context) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
})
router.POST("/timeout", func(c *gin.Context) {
time.Sleep(time.Second)
c.JSON(http.StatusOK, gin.H{})
})
router.POST("/empty-response", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{})
})
router.POST("/pass-header", func(c *gin.Context) {
req := Request{}
if err := c.BindJSON(&req); err != nil {
panic(err)
}
header := req.Headers["x-foo"]
if header == "" {
c.AbortWithStatus(http.StatusBadRequest)
return
}
c.JSON(http.StatusOK, gin.H{
"database_url": "postgres://hostname/" + header,
})
})
router.POST("/success", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"database_url": "postgres://hostname/dbname",
})
})
server := &http.Server{Addr: listenAddr, Handler: router}
mustStartServer(server)
go func() {
<-ctx.Done()
if err := server.Shutdown(context.Background()); err != nil && err != http.ErrServerClosed {
panic(err)
}
}()
}
func mustStartServer(server *http.Server) {
go func() {
err := server.ListenAndServe()
if err != nil && err != http.ErrServerClosed {
panic(err)
}
}()
if err := waitForServer(server.Addr, 5); err != nil {
panic(err)
}
}
func waitForServer(addr string, n int) error {
var lastErr error
for i := 0; i < n; i++ {
conn, err := net.Dial("tcp", addr)
if err == nil {
conn.Close()
return nil
}
lastErr = err
time.Sleep(time.Millisecond * 100)
}
return lastErr
}
+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"`
}
+136 -40
View File
@@ -6,11 +6,21 @@ import (
neturl "net/url" neturl "net/url"
"os" "os"
"os/user" "os/user"
"strconv"
"strings" "strings"
"github.com/jackc/pgpassfile"
"github.com/sosedoff/pgweb/pkg/command" "github.com/sosedoff/pgweb/pkg/command"
) )
// Common errors
var (
errCantDetectUser = errors.New("Could not detect default username")
errInvalidURLFormat = errors.New("Invalid URL. Valid format: postgres://user:password@host:port/db?sslmode=mode")
)
// currentUser returns a current user name
func currentUser() (string, error) { func currentUser() (string, error) {
u, err := user.Current() u, err := user.Current()
if err == nil { if err == nil {
@@ -22,44 +32,91 @@ func currentUser() (string, error) {
return name, nil return name, nil
} }
return "", errors.New("Unable to detect OS user") return "", errCantDetectUser
} }
func FormatUrl(opts command.Options) (string, error) { // Check if connection url has a correct postgres prefix
url := opts.Url func hasValidPrefix(str string) bool {
return strings.HasPrefix(str, "postgres://") || strings.HasPrefix(str, "postgresql://")
}
// Make sure to only accept urls in a standard format // Extract all query vals and return as a map
if !strings.HasPrefix(url, "postgres://") && !strings.HasPrefix(url, "postgresql://") { func valsFromQuery(vals neturl.Values) map[string]string {
return "", errors.New("Invalid URL. Valid format: postgres://user:password@host:port/db?sslmode=mode") result := map[string]string{}
for k, v := range vals {
result[strings.ToLower(k)] = v[0]
}
return result
}
// FormatURL reformats the existing connection string
func FormatURL(opts command.Options) (string, error) {
url := opts.URL
// Validate connection string prefix
if !hasValidPrefix(url) {
return "", errInvalidURLFormat
} }
// Special handling for local connections // Validate the URL
if strings.Contains(url, "localhost") || strings.Contains(url, "127.0.0.1") { uri, err := neturl.Parse(url)
if !strings.Contains(url, "?sslmode") { if err != nil {
if opts.Ssl == "" { return "", errInvalidURLFormat
url += fmt.Sprintf("?sslmode=%s", "disable") }
} else {
url += fmt.Sprintf("?sslmode=%s", opts.Ssl) // Get query params
params := valsFromQuery(uri.Query())
// Determine if we need to specify sslmode if it's missing
if params["sslmode"] == "" {
if opts.SSLMode == "" {
// Only modify sslmode for local connections
if strings.Contains(uri.Host, "localhost") || strings.Contains(uri.Host, "127.0.0.1") {
params["sslmode"] = "disable"
}
} else {
params["sslmode"] = opts.SSLMode
}
}
// When password is not provided, look it up from a .pgpass file
if uri.User != nil {
pass, _ := uri.User.Password()
if pass == "" && opts.Passfile != "" {
pass = lookupPassword(opts, uri)
if pass != "" {
uri.User = neturl.UserPassword(uri.User.Username(), pass)
} }
} }
} }
// Append sslmode parameter only if its defined as a flag and not present // Configure default connect timeout
// in the connection string. if opts.OpenTimeout > 0 {
if !strings.Contains(url, "?sslmode") && opts.Ssl != "" { params["connect_timeout"] = strconv.Itoa(opts.OpenTimeout)
url += fmt.Sprintf("?sslmode=%s", opts.Ssl)
} }
return url, nil // Rebuild query params
query := neturl.Values{}
for k, v := range params {
query.Add(k, v)
}
uri.RawQuery = query.Encode()
return uri.String(), nil
} }
// IsBlank returns true if command options do not contain connection details
func IsBlank(opts command.Options) bool { func IsBlank(opts command.Options) bool {
return opts.Host == "" && opts.User == "" && opts.DbName == "" && opts.Url == "" return opts.Host == "" && opts.User == "" && opts.DbName == "" && opts.URL == ""
} }
func BuildString(opts command.Options) (string, error) { // BuildStringFromOptions returns a new connection string built from options
if opts.Url != "" { func BuildStringFromOptions(opts command.Options) (string, error) {
return FormatUrl(opts) query := neturl.Values{}
// If connection string is provided we just use that
if opts.URL != "" {
return FormatURL(opts)
} }
// Try to detect user from current OS user // Try to detect user from current OS user
@@ -70,32 +127,71 @@ func BuildString(opts command.Options) (string, error) {
} }
} }
// Disable ssl for localhost connections, most users have it disabled if opts.SSLMode != "" {
if opts.Host == "localhost" || opts.Host == "127.0.0.1" { query.Add("sslmode", opts.SSLMode)
if opts.Ssl == "" { } else {
opts.Ssl = "disable" if opts.Host == "localhost" || opts.Host == "127.0.0.1" {
query.Add("sslmode", "disable")
} }
} }
if opts.SSLCert != "" {
url := "postgres://" query.Add("sslcert", opts.SSLCert)
}
if opts.User != "" { if opts.SSLKey != "" {
url += opts.User query.Add("sslkey", opts.SSLKey)
}
if opts.SSLRootCert != "" {
query.Add("sslrootcert", opts.SSLRootCert)
} }
if opts.Pass != "" { // Grab password from .pgpass file if it's available
url += fmt.Sprintf(":%s", neturl.QueryEscape(opts.Pass)) if opts.Pass == "" && opts.Passfile != "" {
opts.Pass = lookupPassword(opts, nil)
} }
url += fmt.Sprintf("@%s:%d", opts.Host, opts.Port) // Configure default connect timeout
if opts.OpenTimeout > 0 {
if opts.DbName != "" { query.Add("connect_timeout", strconv.Itoa(opts.OpenTimeout))
url += fmt.Sprintf("/%s", opts.DbName)
} }
if opts.Ssl != "" { url := neturl.URL{
url += fmt.Sprintf("?sslmode=%s", opts.Ssl) Scheme: "postgres",
Host: fmt.Sprintf("%v:%v", opts.Host, opts.Port),
User: neturl.UserPassword(opts.User, opts.Pass),
Path: fmt.Sprintf("/%s", opts.DbName),
RawQuery: query.Encode(),
} }
return url, nil return url.String(), nil
}
func lookupPassword(opts command.Options, url *neturl.URL) string {
if opts.Passfile == "" {
return ""
}
passfile, err := pgpassfile.ReadPassfile(opts.Passfile)
if err != nil {
fmt.Println("[WARN] .pgpassfile", opts.Passfile, "is not readable")
return ""
}
if url != nil {
var dbName string
fmt.Sscanf(url.Path, "/%s", &dbName) //nolint
return passfile.FindPassword(
url.Hostname(),
url.Port(),
dbName,
url.User.Username(),
)
}
return passfile.FindPassword(
opts.Host,
fmt.Sprintf("%d", opts.Port),
opts.DbName,
opts.User,
)
} }
+263 -145
View File
@@ -2,6 +2,7 @@ package connection
import ( import (
"fmt" "fmt"
"net/url"
"os/user" "os/user"
"testing" "testing"
@@ -9,158 +10,275 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func Test_Invalid_Url(t *testing.T) { func TestBuildStringFromOptions(t *testing.T) {
opts := command.Options{} t.Run("valid url", func(t *testing.T) {
examples := []string{ url := "postgres://myhost/database"
"postgre://foobar", str, err := BuildStringFromOptions(command.Options{URL: url})
"foobar",
assert.NoError(t, err)
assert.Equal(t, url, str)
})
t.Run("with sslmode param", func(t *testing.T) {
str, err := BuildStringFromOptions(command.Options{
URL: "postgres://myhost/database",
SSLMode: "disable",
})
assert.NoError(t, err)
assert.Equal(t, "postgres://myhost/database?sslmode=disable", str)
})
t.Run("sets sslmode param if not set", func(t *testing.T) {
str, err := BuildStringFromOptions(command.Options{
URL: "postgres://localhost/database",
})
assert.NoError(t, err)
assert.Equal(t, "postgres://localhost/database?sslmode=disable", str)
str, err = BuildStringFromOptions(command.Options{
URL: "postgres://127.0.0.1/database",
})
assert.NoError(t, err)
assert.Equal(t, "postgres://127.0.0.1/database?sslmode=disable", str)
})
t.Run("sslmode as an option", func(t *testing.T) {
str, err := BuildStringFromOptions(command.Options{
URL: "postgres://localhost/database",
SSLMode: "require",
})
assert.NoError(t, err)
assert.Equal(t, "postgres://localhost/database?sslmode=require", str)
str, err = BuildStringFromOptions(command.Options{
URL: "postgres://127.0.0.1/database",
SSLMode: "require",
})
assert.NoError(t, err)
assert.Equal(t, "postgres://127.0.0.1/database?sslmode=require", str)
})
t.Run("localhost and sslmode flag", func(t *testing.T) {
str, err := BuildStringFromOptions(command.Options{
URL: "postgres://localhost/database?sslmode=require",
})
assert.NoError(t, err)
assert.Equal(t, "postgres://localhost/database?sslmode=require", str)
str, err = BuildStringFromOptions(command.Options{
URL: "postgres://127.0.0.1/database?sslmode=require",
})
assert.NoError(t, err)
assert.Equal(t, "postgres://127.0.0.1/database?sslmode=require", str)
})
t.Run("extended options", func(t *testing.T) {
str, err := BuildStringFromOptions(command.Options{
URL: "postgres://localhost/database?sslmode=require&sslcert=cert&sslkey=key&sslrootcert=ca",
})
assert.NoError(t, err)
assert.Equal(t, "postgres://localhost/database?sslcert=cert&sslkey=key&sslmode=require&sslrootcert=ca", str)
})
t.Run("from flags", func(t *testing.T) {
str, err := BuildStringFromOptions(command.Options{
Host: "host",
Port: 5432,
User: "user",
Pass: "password",
DbName: "db",
})
assert.NoError(t, err)
assert.Equal(t, "postgres://user:password@host:5432/db", str)
})
t.Run("localhost", func(t *testing.T) {
opts := command.Options{
Host: "localhost",
Port: 5432,
User: "user",
Pass: "password",
DbName: "db",
}
str, err := BuildStringFromOptions(opts)
assert.NoError(t, err)
assert.Equal(t, "postgres://user:password@localhost:5432/db?sslmode=disable", str)
opts.Host = "127.0.0.1"
str, err = BuildStringFromOptions(opts)
assert.NoError(t, err)
assert.Equal(t, "postgres://user:password@127.0.0.1:5432/db?sslmode=disable", str)
})
t.Run("localhost and ssl", func(t *testing.T) {
opts := command.Options{
Host: "localhost",
Port: 5432,
User: "user",
Pass: "password",
DbName: "db",
SSLMode: "require",
SSLKey: "keyPath",
SSLCert: "certPath",
SSLRootCert: "caPath",
}
str, err := BuildStringFromOptions(opts)
assert.NoError(t, err)
assert.Equal(t, "postgres://user:password@localhost:5432/db?sslcert=certPath&sslkey=keyPath&sslmode=require&sslrootcert=caPath", str)
})
t.Run("no user", func(t *testing.T) {
opts := command.Options{Host: "host", Port: 5432, DbName: "db"}
u, _ := user.Current()
str, err := BuildStringFromOptions(opts)
userAndPass := url.UserPassword(u.Username, "").String()
assert.NoError(t, err)
assert.Equal(t, fmt.Sprintf("postgres://%s@host:5432/db", userAndPass), str)
})
t.Run("port", func(t *testing.T) {
opts := command.Options{Host: "host", User: "user", Port: 5000, DbName: "db"}
str, err := BuildStringFromOptions(opts)
assert.NoError(t, err)
assert.Equal(t, "postgres://user:@host:5000/db", str)
})
t.Run("with pgpass", func(t *testing.T) {
opts := command.Options{
Host: "localhost",
Port: 5432,
User: "username",
DbName: "dbname",
Passfile: "../../data/passfile",
}
str, err := BuildStringFromOptions(opts)
assert.NoError(t, err)
assert.Equal(t, "postgres://username:password@localhost:5432/dbname?sslmode=disable", str)
opts.User = "foobar"
str, err = BuildStringFromOptions(opts)
assert.NoError(t, err)
assert.Equal(t, "postgres://foobar:@localhost:5432/dbname?sslmode=disable", str)
opts.Host = "127.0.0.1"
opts.DbName = "foobar2"
str, err = BuildStringFromOptions(opts)
assert.NoError(t, err)
assert.Equal(t, "postgres://foobar:password2@127.0.0.1:5432/foobar2?sslmode=disable", str)
})
t.Run("with connection timeout", func(t *testing.T) {
opts := command.Options{
URL: "postgres://user:pass@localhost:5432/dbname",
OpenTimeout: 30,
}
str, err := BuildStringFromOptions(opts)
assert.NoError(t, err)
assert.Equal(t, "postgres://user:pass@localhost:5432/dbname?connect_timeout=30&sslmode=disable", str)
opts = command.Options{
Host: "localhost",
Port: 5432,
User: "username",
DbName: "dbname",
OpenTimeout: 30,
}
str, err = BuildStringFromOptions(opts)
assert.NoError(t, err)
assert.Equal(t, "postgres://username:@localhost:5432/dbname?connect_timeout=30&sslmode=disable", str)
})
t.Run("invalid url", func(t *testing.T) {
opts := command.Options{}
examples := []string{
"postgre://foobar",
"tcp://blah",
"foobar",
}
for _, val := range examples {
opts.URL = val
str, err := BuildStringFromOptions(opts)
assert.Equal(t, "", str)
assert.Error(t, err)
assert.Equal(t, "Invalid URL. Valid format: postgres://user:password@host:port/db?sslmode=mode", err.Error())
}
})
}
func TestFormatURL(t *testing.T) {
examples := []struct {
name string
input command.Options
result string
err string
}{
{
name: "empty opts",
input: command.Options{},
},
{
name: "invalid url",
input: command.Options{URL: "barurl"},
err: "Invalid URL",
},
{
name: "good",
input: command.Options{
URL: "postgres://user:pass@localhost:5432/dbname",
},
result: "postgres://user:pass@localhost:5432/dbname?sslmode=disable",
},
{
name: "password lookup, password set",
input: command.Options{
URL: "postgres://username:@localhost:5432/dbname",
Passfile: "../../data/passfile",
},
result: "postgres://username:password@localhost:5432/dbname?sslmode=disable",
},
{
name: "password lookup, password not set",
input: command.Options{
URL: "postgres://username@localhost:5432/dbname",
Passfile: "../../data/passfile",
},
result: "postgres://username:password@localhost:5432/dbname?sslmode=disable",
},
{
name: "with timeout setting",
input: command.Options{
URL: "postgres://username@localhost:5432/dbname",
OpenTimeout: 30,
},
result: "postgres://username@localhost:5432/dbname?connect_timeout=30&sslmode=disable",
},
} }
for _, val := range examples { for _, ex := range examples {
opts.Url = val t.Run(ex.name, func(t *testing.T) {
str, err := BuildString(opts) str, err := FormatURL(ex.input)
assert.Equal(t, "", str) if ex.err != "" {
assert.Error(t, err) assert.Error(t, err)
assert.Equal(t, "Invalid URL. Valid format: postgres://user:password@host:port/db?sslmode=mode", err.Error()) assert.Contains(t, err.Error(), ex.err)
}
assert.Equal(t, ex.result, str)
})
} }
} }
func Test_Valid_Url(t *testing.T) { func TestIsBlank(t *testing.T) {
url := "postgres://myhost/database"
str, err := BuildString(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 := BuildString(command.Options{
Url: "postgres://myhost/database",
Ssl: "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 := BuildString(command.Options{
Url: "postgres://localhost/database",
})
assert.Equal(t, nil, err)
assert.Equal(t, "postgres://localhost/database?sslmode=disable", str)
str, err = BuildString(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 := BuildString(command.Options{
Url: "postgres://localhost/database",
Ssl: "require",
})
assert.Equal(t, nil, err)
assert.Equal(t, "postgres://localhost/database?sslmode=require", str)
str, err = BuildString(command.Options{
Url: "postgres://127.0.0.1/database",
Ssl: "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 := BuildString(command.Options{
Url: "postgres://localhost/database?sslmode=require",
})
assert.Equal(t, nil, err)
assert.Equal(t, "postgres://localhost/database?sslmode=require", str)
str, err = BuildString(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_Flag_Args(t *testing.T) {
str, err := BuildString(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",
}
str, err := BuildString(opts)
assert.Equal(t, nil, err)
assert.Equal(t, "postgres://user:password@localhost:5432/db?sslmode=disable", str)
opts.Host = "127.0.0.1"
str, err = BuildString(opts)
assert.Equal(t, nil, err)
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",
Ssl: "require",
}
str, err := BuildString(opts)
assert.Equal(t, nil, err)
assert.Equal(t, "postgres://user:password@localhost:5432/db?sslmode=require", str)
}
func Test_No_User(t *testing.T) {
opts := command.Options{Host: "host", Port: 5432, DbName: "db"}
u, _ := user.Current()
str, err := BuildString(opts)
assert.Equal(t, nil, err)
assert.Equal(t, fmt.Sprintf("postgres://%s@host:5432/db", u.Username), str)
}
func Test_Port(t *testing.T) {
opts := command.Options{Host: "host", User: "user", Port: 5000, DbName: "db"}
str, err := BuildString(opts)
assert.Equal(t, nil, err)
assert.Equal(t, "postgres://user@host:5000/db", str)
}
func Test_Blank(t *testing.T) {
assert.Equal(t, true, IsBlank(command.Options{})) 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"}))
assert.Equal(t, false, IsBlank(command.Options{Host: "host", User: "user", DbName: "db"})) assert.Equal(t, false, IsBlank(command.Options{Host: "host", User: "user", DbName: "db"}))
assert.Equal(t, false, IsBlank(command.Options{Url: "url"})) assert.Equal(t, false, IsBlank(command.Options{URL: "url"}))
} }
+6 -10
View File
@@ -7,25 +7,21 @@ import (
"strings" "strings"
) )
// Check if the TCP port available on localhost // IsPortAvailable returns true if there's no listeners on a given port
func portAvailable(port int) bool { func IsPortAvailable(port int) bool {
conn, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%v", port)) conn, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%v", port))
if err != nil { if err != nil {
if strings.Index(err.Error(), "connection refused") > 0 { return strings.Index(err.Error(), "connection refused") > 0
return true
}
return false
} }
conn.Close() conn.Close()
return false return false
} }
// Get available TCP port on localhost by trying available ports in a range // FindAvailablePort returns the first available TCP port in the range
func AvailablePort(start int, limit int) (int, error) { func FindAvailablePort(start int, limit int) (int, error) {
for i := start; i <= (start + limit); i++ { for i := start; i <= (start + limit); i++ {
if portAvailable(i) { if IsPortAvailable(i) {
return i, nil return i, nil
} }
} }
+12 -12
View File
@@ -10,14 +10,14 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func Test_portAvailable(t *testing.T) { func TestIsPortAvailable(t *testing.T) {
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
t.Skip("FIXME") t.Skip("FIXME")
} }
assert.Equal(t, true, portAvailable(8081)) assert.Equal(t, true, IsPortAvailable(30000))
serv, err := net.Listen("tcp", "127.0.0.1:8081") serv, err := net.Listen("tcp", "127.0.0.1:30000")
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "Unable to start test tcp listener:", err) fmt.Fprintln(os.Stderr, "Unable to start test tcp listener:", err)
t.Fail() t.Fail()
@@ -35,20 +35,20 @@ func Test_portAvailable(t *testing.T) {
} }
}() }()
assert.Equal(t, false, portAvailable(8081)) assert.Equal(t, false, IsPortAvailable(30000))
assert.Equal(t, true, portAvailable(8082)) assert.Equal(t, true, IsPortAvailable(30001))
} }
func Test_getAvailablePort(t *testing.T) { func TestFindAvailablePort(t *testing.T) {
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
t.Skip("FIXME") t.Skip("FIXME")
} }
port, err := AvailablePort(8081, 1) port, err := FindAvailablePort(30000, 1)
assert.Equal(t, nil, err) assert.Equal(t, nil, err)
assert.Equal(t, 8081, port) assert.Equal(t, 30000, port)
serv, err := net.Listen("tcp", "127.0.0.1:8081") serv, err := net.Listen("tcp", "127.0.0.1:30000")
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "Unable to start test tcp listener:", err) fmt.Fprintln(os.Stderr, "Unable to start test tcp listener:", err)
t.Fail() t.Fail()
@@ -65,11 +65,11 @@ func Test_getAvailablePort(t *testing.T) {
} }
}() }()
port, err = AvailablePort(8081, 0) port, err = FindAvailablePort(30000, 0)
assert.EqualError(t, err, "No available port") assert.EqualError(t, err, "No available port")
assert.Equal(t, -1, port) assert.Equal(t, -1, port)
port, err = AvailablePort(8081, 1) port, err = FindAvailablePort(30000, 1)
assert.Equal(t, nil, err) assert.Equal(t, nil, err)
assert.Equal(t, 8082, port) assert.Equal(t, 30001, port)
} }
-613
View File
File diff suppressed because one or more lines are too long
+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)
}
})
}
}
+7 -5
View File
@@ -4,12 +4,14 @@ import (
"fmt" "fmt"
) )
// SSHInfo contains ssh server configuration
type SSHInfo struct { type SSHInfo struct {
Host string `json:"host,omitempty"` Host string `json:"host,omitempty"`
Port string `json:"port,omitempty"` Port string `json:"port,omitempty"`
User string `json:"user,omitempty"` User string `json:"user,omitempty"`
Password string `json:"password,omitempty"` Password string `json:"password,omitempty"`
Key string `json:"key,omitempty"` Key string `json:"key,omitempty"`
KeyPassword string `json:"keypassword,omitempty"`
} }
func (info SSHInfo) String() string { func (info SSHInfo) String() string {
+60 -150
View File
@@ -1,153 +1,63 @@
package statements package statements
const ( import (
Databases = ` _ "embed"
SELECT )
datname
FROM var (
pg_database //go:embed sql/databases.sql
WHERE Databases string
NOT datistemplate
ORDER BY //go:embed sql/schemas.sql
datname ASC` Schemas string
// --------------------------------------------------------------------------- //go:embed sql/info.sql
Info string
Schemas = `
SELECT //go:embed sql/info_simple.sql
schema_name InfoSimple string
FROM
information_schema.schemata //go:embed sql/estimated_row_count.sql
ORDER BY EstimatedTableRowCount string
schema_name ASC`
//go:embed sql/table_indexes.sql
// --------------------------------------------------------------------------- TableIndexes string
Info = ` //go:embed sql/table_constraints.sql
SELECT TableConstraints string
session_user,
current_user, //go:embed sql/table_info.sql
current_database(), TableInfo string
current_schemas(false),
inet_client_addr(), //go:embed sql/table_info_cockroach.sql
inet_client_port(), TableInfoCockroach string
inet_server_addr(),
inet_server_port(), //go:embed sql/table_schema.sql
version()` TableSchema string
// --------------------------------------------------------------------------- //go:embed sql/materialized_view.sql
MaterializedView string
TableIndexes = `
SELECT //go:embed sql/objects.sql
indexname, indexdef Objects string
FROM
pg_indexes //go:embed sql/tables_stats.sql
WHERE TablesStats string
schemaname = $1 AND
tablename = $2` //go:embed sql/function.sql
Function string
// ---------------------------------------------------------------------------
//go:embed sql/settings.sql
TableConstraints = ` Settings string
SELECT
pg_get_constraintdef(c.oid, true) as condef // Activity queries for specific PG versions
FROM Activity = map[string]string{
pg_constraint c "default": "SELECT * FROM pg_stat_activity WHERE datname = current_database()",
JOIN "9.1": "SELECT datname, current_query, waiting, query_start, procpid as pid, datid, application_name, client_addr FROM pg_stat_activity WHERE datname = current_database()",
pg_namespace n ON n.oid = c.connamespace "9.2": "SELECT datname, query, state, waiting, query_start, state_change, pid, datid, application_name, client_addr FROM pg_stat_activity WHERE datname = current_database()",
JOIN "9.3": "SELECT datname, query, state, waiting, query_start, state_change, pid, datid, application_name, client_addr FROM pg_stat_activity WHERE datname = current_database()",
pg_class cl ON cl.oid = c.conrelid "9.4": "SELECT datname, query, state, waiting, query_start, state_change, pid, datid, application_name, client_addr FROM pg_stat_activity WHERE datname = current_database()",
WHERE "9.5": "SELECT datname, query, state, waiting, query_start, state_change, pid, datid, application_name, client_addr FROM pg_stat_activity WHERE datname = current_database()",
n.nspname = $1 AND "9.6": "SELECT datname, query, state, wait_event, wait_event_type, query_start, state_change, pid, datid, application_name, client_addr FROM pg_stat_activity WHERE datname = current_database()",
relname = $2 }
ORDER BY
contype desc`
// ---------------------------------------------------------------------------
TableInfo = `
SELECT
pg_size_pretty(pg_table_size($1)) AS data_size,
pg_size_pretty(pg_indexes_size($1)) AS index_size,
pg_size_pretty(pg_total_relation_size($1)) AS total_size,
(SELECT reltuples FROM pg_class WHERE oid = $1::regclass) AS rows_count`
// ---------------------------------------------------------------------------
TableSchema = `
SELECT
column_name,
data_type,
is_nullable,
character_maximum_length,
character_set_catalog,
column_default
FROM
information_schema.columns
WHERE
table_schema = $1 AND
table_name = $2`
// ---------------------------------------------------------------------------
MaterializedView = `
SELECT
attname as column_name,
atttypid::regtype AS data_type,
(case when attnotnull IS TRUE then 'NO' else 'YES' end) as is_nullable,
null as character_maximum_length,
null as character_set_catalog,
null as column_default
FROM
pg_attribute
WHERE
attrelid = $1::regclass AND
attnum > 0 AND
NOT attisdropped`
// ---------------------------------------------------------------------------
Activity = `
SELECT
datname,
query,
state,
waiting,
query_start,
state_change,
pid,
datid,
application_name,
client_addr
FROM
pg_stat_activity
WHERE
state IS NOT NULL`
// ---------------------------------------------------------------------------
Objects = `
SELECT
n.nspname as "schema",
c.relname as "name",
CASE c.relkind
WHEN 'r' THEN 'table'
WHEN 'v' THEN 'view'
WHEN 'm' THEN 'materialized_view'
WHEN 'i' THEN 'index'
WHEN 'S' THEN 'sequence'
WHEN 's' THEN 'special'
WHEN 'f' THEN 'foreign_table'
END as "type",
pg_catalog.pg_get_userbyid(c.relowner) as "owner"
FROM
pg_catalog.pg_class c
LEFT JOIN
pg_catalog.pg_namespace n ON n.oid = c.relnamespace
WHERE
c.relkind IN ('r','v','m','S','s','') AND
n.nspname !~ '^pg_toast' AND
n.nspname NOT IN ('information_schema', 'pg_catalog') AND
has_schema_privilege(n.nspname, 'USAGE')
ORDER BY 1, 2`
) )
+8
View File
@@ -0,0 +1,8 @@
SELECT
datname
FROM
pg_database
WHERE
NOT datistemplate
ORDER BY
datname ASC
@@ -0,0 +1,6 @@
SELECT
reltuples
FROM
pg_class
WHERE
oid = ('"' || $1::text || '"."' || $2::text || '"')::regclass
+10
View File
@@ -0,0 +1,10 @@
SELECT
p.oid,
p.proname,
p.pronamespace,
p.proowner,
pg_get_functiondef(oid) AS functiondef
FROM
pg_catalog.pg_proc p
WHERE
oid = $1::oid
+10
View File
@@ -0,0 +1,10 @@
SELECT
session_user,
current_user,
current_database(),
current_schemas(false),
inet_client_addr(),
inet_client_port(),
inet_server_addr(),
inet_server_port(),
version()

Some files were not shown because too many files have changed in this diff Show More