Compare commits

...
99 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
71 changed files with 2263 additions and 523 deletions
+7 -7
View File
@@ -3,7 +3,7 @@ name: checks
on:
push:
branches:
- master
- main
paths-ignore:
- '**.md'
pull_request:
@@ -15,7 +15,7 @@ on:
env:
GO_VERSION: 1.19
GO_VERSION: "1.25"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -25,10 +25,10 @@ jobs:
tests:
name: tests
runs-on: ubuntu-latest
timeout-minutes: 30
timeout-minutes: 40
strategy:
matrix:
pg_version: [9.6, 10, 11, 12, 13, 14, 15]
pg_version: [9.6, 10, 11, 12, 13, 14, 15, 16, 17, 18]
services:
postgres:
@@ -50,7 +50,7 @@ jobs:
sudo rm -f /etc/apt/sources.list.d/pgdg.list
curl --silent https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add
echo "deb http://apt.postgresql.org/pub/repos/apt/ `lsb_release -cs`-pgdg main" | sudo tee /etc/apt/sources.list.d/pgdg.list
sudo apt-get update && sudo apt-get install -y postgresql-client-15
sudo apt-get update && sudo apt-get install -y postgresql-client-18
- uses: actions/checkout@v3
with:
@@ -95,9 +95,9 @@ jobs:
fetch-depth: 0
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
uses: golangci/golangci-lint-action@v8
with:
version: v1.50.1
version: v2.7.2
fmt:
name: fmt
+1 -1
View File
@@ -3,7 +3,7 @@ name: demo deploy
on:
push:
branches:
- master
- main
env:
FLY_API_TOKEN: ${{ secrets.FLY_TOKEN }}
+5 -3
View File
@@ -3,10 +3,10 @@ name: docker
on:
push:
branches:
- master
- main
env:
GO_VERSION: 1.19
GO_VERSION: "1.25"
CGO_ENABLED: 0
IMAGE_REPOSITORY: sosedoff/pgweb
@@ -32,4 +32,6 @@ jobs:
context: .
push: false
tags: pgweb:latest
platforms: linux/amd64,linux/arm64,linux/arm/v5,linux/arm/v7
platforms: linux/amd64,linux/arm64,linux/arm/v7
build-args: |
"CGO_ENABLED=${{ env.CGO_ENABLED }}"
+2 -2
View File
@@ -6,7 +6,7 @@ on:
- "v*"
env:
GO_VERSION: 1.19
GO_VERSION: "1.25"
CGO_ENABLED: 0
DOCKER_REPOSITORY: sosedoff/pgweb
GHCR_REPOSITORY: sosedoff/pgweb
@@ -57,4 +57,4 @@ jobs:
${{ env.DOCKER_REPOSITORY }}:latest
ghcr.io/${{ env.GHCR_REPOSITORY }}:${{ steps.refs.outputs.SOURCE_TAG }}
ghcr.io/${{ env.GHCR_REPOSITORY }}:latest
platforms: linux/amd64,linux/arm64,linux/arm/v5,linux/arm/v7
platforms: linux/amd64,linux/arm64,linux/arm/v7
+2
View File
@@ -1,5 +1,7 @@
.DS_Store
.idea
.env
.envrc
pgweb
bin
tmp/
+7
View File
@@ -0,0 +1,7 @@
version: "2"
linters:
disable:
- errcheck
settings:
staticcheck:
checks: ["all", "-ST1000", "-ST1003", "-ST1016", "-ST1020", "-ST1021", "-ST1022", "-ST1005", "-QF1004"]
+95 -5
View File
@@ -1,4 +1,94 @@
# 0.12.0 - 2022-12-13
## 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
@@ -25,12 +115,12 @@
- 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
## 0.11.12 - 2022-07-05
- Update base docker image (alpine), update deps, GH-558
- Refactor docker images building, include ARM, GH-568
# 0.11.11 - 2022-03-29
## 0.11.11 - 2022-03-29
- Auto-detect the query from the query source based on user selection, GH-547
- Added binary codec base58 as well as improving the help for --binary-codec flag, GH-548
@@ -295,7 +385,7 @@
## 0.6.0 - 2015-05-31
- Adds ability to execute only selected SQL query in run command view, [GH-85]
- Adds ability to delete/truncate table via context meny on sidebar view
- Adds ability to delete/truncate table via context many on sidebar view
- Adds ability to export table contents to CSV via context menu on sidebar view
- Changes sidebar color scheme to a lighter and better looking one
@@ -309,7 +399,7 @@
## 0.5.2 - 2015-04-13
- Adds a new endpoint /activity that retuns active queries
- Adds a new endpoint /activity that returns active queries
- Adds tab to view active queries
- Adds column sorting when browsing table contents
- Fixes SQL query view when switching to table structure view
+1 -1
View File
@@ -2,6 +2,6 @@
- Create a new git branch
- Make changes
- Run tests: `make test`
- Run tests agains all supported PostreSQL versions: `make test-all` (optional)
- Run tests against all supported PostreSQL versions: `make test-all` (optional)
- If you change frontend code (js/css) make sure to rebuild assets: `make assets`
- Open a new pull request
+30 -18
View File
@@ -1,36 +1,48 @@
# ------------------------------------------------------------------------------
# Builder Stage
# ------------------------------------------------------------------------------
FROM golang:1.19-bullseye AS build
FROM golang:1.25-trixie AS build
# Set default build argument for CGO_ENABLED
ARG CGO_ENABLED=0
ENV CGO_ENABLED=${CGO_ENABLED}
WORKDIR /build
ADD . /build
RUN git config --global --add safe.directory /build
COPY go.mod go.sum ./
RUN go mod download
COPY Makefile main.go ./
COPY static/ static/
COPY pkg/ pkg/
COPY .git/ .
RUN make build
# ------------------------------------------------------------------------------
# Fetch signing key
# ------------------------------------------------------------------------------
FROM debian:trixie-slim AS keyring
ADD https://www.postgresql.org/media/keys/ACCC4CF8.asc keyring.asc
RUN apt-get update && \
apt-get install -qq --no-install-recommends gpg
RUN gpg -o keyring.pgp --dearmor keyring.asc
# ------------------------------------------------------------------------------
# Release Stage
# ------------------------------------------------------------------------------
FROM debian:bullseye-slim
FROM debian:trixie-slim
RUN \
ARG keyring=/usr/share/keyrings/postgresql-archive-keyring.pgp
COPY --from=keyring /keyring.pgp $keyring
RUN . /etc/os-release && \
echo "deb [signed-by=${keyring}] http://apt.postgresql.org/pub/repos/apt/ ${VERSION_CODENAME}-pgdg main" > /etc/apt/sources.list.d/pgdg.list && \
apt-get update && \
apt-get install -y ca-certificates openssl netcat curl gnupg lsb-release && \
update-ca-certificates
RUN \
curl --silent https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add && \
echo "deb http://apt.postgresql.org/pub/repos/apt/ `lsb_release -cs`-pgdg main" | tee /etc/apt/sources.list.d/pgdg.list && \
apt-get update && apt-get install -y postgresql-client
RUN \
apt-get clean autoclean && \
apt-get autoremove --yes && \
rm -rf /var/lib/{apt,dpkg,cache,log}/
apt-get install -qq --no-install-recommends ca-certificates openssl netcat-openbsd curl postgresql-client
COPY --from=build /build/pgweb /usr/bin/pgweb
EXPOSE 8081
RUN useradd --uid 1000 --no-create-home --shell /bin/false pgweb
USER pgweb
CMD ["/usr/bin/pgweb", "--bind=0.0.0.0", "--listen=8081"]
EXPOSE 8081
ENTRYPOINT ["/usr/bin/pgweb", "--bind=0.0.0.0", "--listen=8081"]
+1 -1
View File
@@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2014-2022 Dan Sosedoff <dan.sosedoff@gmail.com>
Copyright (c) 2014-2024 Dan Sosedoff <dan.sosedoff@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
+1 -1
View File
@@ -1,5 +1,5 @@
PKG = github.com/sosedoff/pgweb
GIT_COMMIT ?= $(shell git rev-parse HEAD)
GIT_COMMIT ?= $(shell git rev-parse --short=8 HEAD)
BUILD_TIME ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ" | tr -d '\n')
GO_VERSION ?= $(shell go version | awk {'print $$3'})
+19 -35
View File
@@ -1,59 +1,43 @@
# pgweb
Web-based PostgreSQL database browser written in Go.
Simple web-based and cross platform PostgreSQL database explorer.
[![Release](https://img.shields.io/github/release/sosedoff/pgweb.svg?label=Release)](https://github.com/sosedoff/pgweb/releases)
[![Linux Build](https://github.com/sosedoff/pgweb/actions/workflows/checks.yml/badge.svg)](https://github.com/sosedoff/pgweb/actions?query=branch%3Amaster)
[![Linux Build](https://github.com/sosedoff/pgweb/actions/workflows/checks.yml/badge.svg)](https://github.com/sosedoff/pgweb/actions?query=branch%3Amain)
[![Go Report Card](https://goreportcard.com/badge/github.com/sosedoff/pgweb)](https://goreportcard.com/report/github.com/sosedoff/pgweb)
[![GoDoc](https://godoc.org/github.com/sosedoff/pgweb?status.svg)](https://godoc.org/github.com/sosedoff/pgweb)
[![Docker Pulls](https://img.shields.io/docker/pulls/sosedoff/pgweb.svg)](https://hub.docker.com/r/sosedoff/pgweb/)
## Overview
Pgweb is a web-based database browser for PostgreSQL, written in Go and works
on OSX, Linux and Windows machines. Main idea behind using Go for backend development
is to utilize ability of the compiler to produce zero-dependency binaries for
multiple platforms. Pgweb was created as an attempt to build very simple and portable
application to work with local or remote PostgreSQL databases.
Pgweb is a web-based database explorer for PostgreSQL, written in Go, and works
on Mac, Linux and Windows machines. Distributed as a simple binary with zero dependencies.
Very easy to use and packs just the right amount of features.
[See application screenshots](SCREENS.md)
## Features
- Cross-platform support OSX/Linux/Windows 32/64-bit
- Simple installation (distributed as a single binary)
- Zero dependencies
- Works with PostgreSQL 9.1+
- SSH Connections
- Multiple database sessions
- Simple database browser
- Execute and analyze custom SQL queries
- Table and query data export to CSV/JSON/XML
- Query history
- Server bookmarks
- Cross-platform: Mac/Linux/Windows (64bit).
- Simple installation (distributed as a single binary).
- Zero dependencies.
- Works with PostgreSQL 9.1+.
- Supports native SSH tunnels.
- Multiple database sessions.
- Execute and analyze custom SQL queries.
- Table and query data export to CSV/JSON/XML.
- Query history.
- Server bookmarks.
Visit [WIKI](https://github.com/sosedoff/pgweb/wiki) for more details
## Pgweb Pro
Pgweb Pro is the next major version of Pgweb and includes features like:
- Table structure editing
- Data editing (update row content)
- Charting
- History persistence
- Multiple tabs
Please get in touch via: https://goo.gl/forms/euQOGWg5uPdPH70b2
Visit [WIKI](https://github.com/sosedoff/pgweb/wiki) for more details.
## Demo
Visit https://pgweb-demo.fly.dev/ to see pgweb in action.
Visit https://pgweb-demo.fly.dev/ to see Pgweb in action.
## Installation
- [Precompiled binaries](https://github.com/sosedoff/pgweb/releases) for supported
operating systems are available.
- [Precompiled binaries](https://github.com/sosedoff/pgweb/releases) for supported operating systems are available.
- [More installation options](https://github.com/sosedoff/pgweb/wiki/Installation)
## Usage
@@ -88,7 +72,7 @@ pgweb --sessions
Or set environment variable:
```
SESSIONS=1 pgweb
PGWEB_SESSIONS=1 pgweb
```
## Testing
-27
View File
@@ -1,27 +0,0 @@
{
"name": "pgweb",
"description": "A web-based frontend to your Postgres database.",
"website": "https://github.com/sosedoff/pgweb",
"repository": "https://github.com/sosedoff/pgweb",
"logo": "https://raw.githubusercontent.com/sosedoff/pgweb/master/static/img/icon.png",
"success_url": "/",
"keywords": [
"postgres",
"database",
"golang",
"frontend"
],
"addons": [
"heroku-postgresql"
],
"env": {
"AUTH_USER": {
"description": "A username to log into pgweb.",
"value": "admin"
},
"AUTH_PASS": {
"description": "The login password. Please change this.",
"generator": "secret"
}
}
}
@@ -3,3 +3,4 @@ source "https://rubygems.org"
gem "sinatra"
gem "json"
gem "puma"
gem "rackup"
@@ -1,29 +1,43 @@
GEM
remote: https://rubygems.org/
specs:
json (2.6.2)
mustermann (3.0.0)
base64 (0.3.0)
json (2.12.2)
logger (1.7.0)
mustermann (3.0.3)
ruby2_keywords (~> 0.0.1)
nio4r (2.5.8)
puma (6.0.0)
nio4r (2.7.4)
puma (6.6.0)
nio4r (~> 2.0)
rack (2.2.4)
rack-protection (3.0.4)
rack
rack (3.1.16)
rack-protection (4.1.1)
base64 (>= 0.1.0)
logger (>= 1.6.0)
rack (>= 3.0.0, < 4)
rack-session (2.1.1)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rackup (2.2.1)
rack (>= 3)
ruby2_keywords (0.0.5)
sinatra (3.0.4)
sinatra (4.1.1)
logger (>= 1.6.0)
mustermann (~> 3.0)
rack (~> 2.2, >= 2.2.4)
rack-protection (= 3.0.4)
rack (>= 3.0.0, < 4)
rack-protection (= 4.1.1)
rack-session (>= 2.0.0, < 3)
tilt (~> 2.0)
tilt (2.0.11)
tilt (2.6.0)
PLATFORMS
arm64-darwin-21
arm64-darwin-24
x86_64-linux
DEPENDENCIES
json
puma
rackup
sinatra
BUNDLED WITH
+2 -1
View File
@@ -1,9 +1,10 @@
require "bundler/setup"
require "sinatra"
# Authentication token
$token = "test"
# List of all availble resources
# List of all available resources
$resources = {
"id1" => "postgres://localhost:5432/db1?sslmode=disable",
"id2" => "postgres://localhost:5432/db2?sslmode=disable",
+12
View File
@@ -0,0 +1,12 @@
host = "localhost"
port = 5432
user = "postgres"
database = "mydatabase"
ssl = "disable"
[SSH]
host = "ssh-host"
user = "ssh-user"
password = "ssh-password"
key = "/path/to/key-file"
keypassword = "key-file-password"
+2
View File
@@ -0,0 +1,2 @@
-- pgweb: host="localhost"
select 'foo'
+5
View File
@@ -0,0 +1,5 @@
-- pgweb: host="localhost"
-- some comment
-- pgweb: user="foo"
select 'foo'
+2
View File
@@ -0,0 +1,2 @@
-- pgweb: host="localhost" mode="foo"
select 'foo'
+1
View File
@@ -0,0 +1 @@
select 'foo'
+15 -2
View File
@@ -1,6 +1,4 @@
---
version: "3.9"
x-base: &base
environment: &env
POSTGRES_DB: pgweb
@@ -11,6 +9,21 @@ x-base: &base
interval: 5s
services:
postgres18:
<<: *base
image: postgres:18
ports:
- 5433:5432
postgres17:
<<: *base
image: postgres:17
ports:
- 5433:5432
postgres16:
<<: *base
image: postgres:16
ports:
- 5433:5432
postgres15:
<<: *base
image: postgres:15
+3 -4
View File
@@ -1,10 +1,8 @@
---
version: "3.9"
services:
postgres:
container_name: pgweb-postgres
image: postgres:14
image: postgres:15
ports:
- 5433:5432
volumes:
@@ -22,8 +20,9 @@ services:
pgweb:
container_name: pgweb
image: sosedoff/pgweb:latest
build: .
environment:
DATABASE_URL: postgres://pgweb:pgweb@pgweb-postgres:5432/pgweb?sslmode=disable
PGWEB_DATABASE_URL: postgres://pgweb:pgweb@pgweb-postgres:5432/pgweb?sslmode=disable
ports:
- 8081:8081
networks:
+5 -1
View File
@@ -3,7 +3,7 @@ kill_signal = "SIGINT"
kill_timeout = 5
[processes]
web = "pgweb --sessions --bind=0.0.0.0 --metrics"
web = "pgweb --sessions --bind=0.0.0.0 --metrics --idle-timeout=30"
[[services]]
http_checks = []
@@ -31,3 +31,7 @@ kill_timeout = 5
interval = "15s"
restart_limit = 0
timeout = "2s"
[metrics]
port = 8081
path = "/metrics"
+37 -23
View File
@@ -1,50 +1,64 @@
module github.com/sosedoff/pgweb
go 1.19
go 1.25
// +heroku goVersion go1.19
toolchain go1.25.4
require (
github.com/BurntSushi/toml v1.1.0
github.com/ScaleFT/sshkeys v0.0.0-20200327173127-6142f742bca5
github.com/gin-gonic/gin v1.7.7
github.com/gin-gonic/gin v1.11.0
github.com/jackc/pgpassfile v1.0.0
github.com/jessevdk/go-flags v1.5.0
github.com/jmoiron/sqlx v1.3.5
github.com/lib/pq v1.10.5
github.com/mitchellh/go-homedir v1.1.0
github.com/mr-tron/base58 v1.2.0
github.com/prometheus/client_golang v1.14.0
github.com/sirupsen/logrus v1.9.0
github.com/stretchr/testify v1.8.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
golang.org/x/crypto v0.0.0-20220511200225-c6db032c6c88
go.balki.me/anyhttp v0.5.0
golang.org/x/crypto v0.46.0
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.14.2 // indirect
github.com/bytedance/sonic/loader v0.4.0 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dchest/bcrypt_pbkdf v0.0.0-20150205184540-83f37f9c154a // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.0 // indirect
github.com/go-playground/universal-translator v0.18.0 // indirect
github.com/go-playground/validator/v10 v10.9.0 // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.1 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/leodido/go-urn v1.2.1 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.4 // 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.3.0 // indirect
github.com/prometheus/common v0.39.0 // indirect
github.com/prometheus/procfs v0.8.0 // indirect
github.com/ugorji/go/codec v1.2.6 // indirect
golang.org/x/sys v0.3.0 // indirect
golang.org/x/text v0.5.0 // indirect
google.golang.org/protobuf v1.28.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
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
)
+83 -93
View File
@@ -4,39 +4,44 @@ github.com/ScaleFT/sshkeys v0.0.0-20200327173127-6142f742bca5 h1:VauE2GcJNZFun2O
github.com/ScaleFT/sshkeys v0.0.0-20200327173127-6142f742bca5/go.mod h1:gxOHeajFfvGQh/fxlC8oOKBe23xnnJTif00IFFbiT+o=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dchest/bcrypt_pbkdf v0.0.0-20150205184540-83f37f9c154a h1:saTgr5tMLFnmy/yg3qDTft4rE5DY2uJ/cCxCe3q0XTU=
github.com/dchest/bcrypt_pbkdf v0.0.0-20150205184540-83f37f9c154a/go.mod h1:Bw9BbhOJVNR+t0jCqx2GC6zv0TGBsShs56Y3gfSCvl0=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.7.7 h1:3DoBmSbJbZAWqXJC3SLjAPfutPJJRN1U5pALB7EeTTs=
github.com/gin-gonic/gin v1.7.7/go.mod h1:axIBovoeJpVj8S3BwE0uPMTeReE4+AfFtqpqaZ1qq1U=
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU=
github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho=
github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
github.com/go-playground/validator/v10 v10.9.0 h1:NgTtmN58D0m8+UuxtYmGztBJB7VnPgjj221I1QHci2A=
github.com/go-playground/validator/v10 v10.9.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/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=
@@ -44,118 +49,103 @@ github.com/jessevdk/go-flags v1.5.0 h1:1jKYvbxEjfUl0fmqTCOfonvskHHXMjBySTLW4y9LF
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=
github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.10.5 h1:J+gdV2cUmX7ZqL2B0lFcW0m+egaHC2V3lpO8nWxyYiQ=
github.com/lib/pq v1.10.5/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg=
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/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.14.0 h1:nJdhIvne2eSX/XRAFV9PcvFFRbrjbcTUj0VP62TMhnw=
github.com/prometheus/client_golang v1.14.0/go.mod h1:8vpkKitgIVNcqrRBWh1C4TIUQgYNtG/XQE4E/Zae36Y=
github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4=
github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w=
github.com/prometheus/common v0.39.0 h1:oOyhkDq05hPZKItWVBkJ6g6AtGxi+fy7F4JvUV8uhsI=
github.com/prometheus/common v0.39.0/go.mod h1:6XBZ7lYdLCbkAVhwRsWTZn+IN5AB9F/NXd5w0BbEX0Y=
github.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5mo=
github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE=
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.58.0 h1:ggY2pvZaVdB9EyojxL1p+5mptkuHyX5MOSv4dgWF4Ug=
github.com/quic-go/quic-go v0.58.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/sirupsen/logrus v1.9.1 h1:Ou41VVR3nMWWmTiEUnj0OlsgOSCUFgsPAOl6jRIcVtQ=
github.com/sirupsen/logrus v1.9.1/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tuvistavie/securerandom v0.0.0-20140719024926-15512123a948 h1:yL0l/u242MzDP6D0B5vGC+wxm5WRY+alQZy+dJk3bFI=
github.com/tuvistavie/securerandom v0.0.0-20140719024926-15512123a948/go.mod h1:a06d/M1pxWi51qiSrfGMHaEydtuXT06nha8N2aNQuXk=
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
github.com/ugorji/go v1.2.6/go.mod h1:anCg0y61KIhDlPZmnH+so+RQbysYVyDko0IMgJv0Nn0=
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
github.com/ugorji/go/codec v1.2.6 h1:7kbGefxLoDBuYXOms4yD7223OpNMMPNPZxXk5TvFcyQ=
github.com/ugorji/go/codec v1.2.6/go.mod h1:V6TCNZ4PHqoHGFZuSG1W8nrCzzdgA2DozYxWFFpvxTw=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.balki.me/anyhttp v0.5.0 h1:uys0oRciBpZfwtxXAevScKy6amIQBXyDrcV0EtGF5zo=
go.balki.me/anyhttp v0.5.0/go.mod h1:JhfekOIjgVODoVqUCficjpIgmB3wwlB7jhN0eN2EZ/s=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220511200225-c6db032c6c88 h1:Tgea0cVUD0ivh5ADBX4WwuI12DUd2to3nCYe2eayMIw=
golang.org/x/crypto v0.0.0-20220511200225-c6db032c6c88/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200219091948-cb0a6d8edb6c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.5.0 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM=
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/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=
+138 -6
View File
@@ -15,8 +15,10 @@ import (
"github.com/sosedoff/pgweb/pkg/bookmarks"
"github.com/sosedoff/pgweb/pkg/client"
"github.com/sosedoff/pgweb/pkg/command"
"github.com/sosedoff/pgweb/pkg/connect"
"github.com/sosedoff/pgweb/pkg/connection"
"github.com/sosedoff/pgweb/pkg/metrics"
"github.com/sosedoff/pgweb/pkg/queries"
"github.com/sosedoff/pgweb/pkg/shared"
"github.com/sosedoff/pgweb/static"
)
@@ -27,6 +29,9 @@ var (
// DbSessions represents the mapping for client connections
DbSessions *SessionManager
// QueryStore reads the SQL queries stores in the home directory
QueryStore *queries.Store
)
// DB returns a database connection from the client context
@@ -88,18 +93,18 @@ func GetSessions(c *gin.Context) {
// ConnectWithBackend creates a new connection based on backend resource
func ConnectWithBackend(c *gin.Context) {
// Setup a new backend client
backend := Backend{
Endpoint: command.Opts.ConnectBackend,
Token: command.Opts.ConnectToken,
PassHeaders: strings.Split(command.Opts.ConnectHeaders, ","),
backend := connect.NewBackend(command.Opts.ConnectBackend, command.Opts.ConnectToken)
backend.SetLogger(logger)
if command.Opts.ConnectHeaders != "" {
backend.SetPassHeaders(strings.Split(command.Opts.ConnectHeaders, ","))
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
// Fetch connection credentials
cred, err := backend.FetchCredential(ctx, c.Param("resource"), c)
cred, err := backend.FetchCredential(ctx, c.Param("resource"), c.Request.Header)
if err != nil {
badRequest(c, err)
return
@@ -150,6 +155,8 @@ func Connect(c *gin.Context) {
if bookmarkID := c.Request.FormValue("bookmark_id"); bookmarkID != "" {
cl, err = ConnectWithBookmark(bookmarkID)
} else if command.Opts.BookmarksOnly {
err = errNotPermitted
} else {
cl, err = ConnectWithURL(c)
}
@@ -297,6 +304,7 @@ func Disconnect(c *gin.Context) {
return
}
DbClient = nil
successResponse(c, gin.H{"success": true})
}
@@ -479,6 +487,12 @@ func GetConnectionInfo(c *gin.Context) {
successResponse(c, info)
}
// GetServerSettings renders a list of all server settings
func GetServerSettings(c *gin.Context) {
res, err := DB(c).ServerSettings()
serveResult(c, res, err)
}
// GetActivity renders a list of running queries
func GetActivity(c *gin.Context) {
res, err := DB(c).Activity()
@@ -497,6 +511,47 @@ func GetTableConstraints(c *gin.Context) {
serveResult(c, res, err)
}
// GetTablesStats renders data sizes and estimated rows for all tables in the database
func GetTablesStats(c *gin.Context) {
db := DB(c)
connCtx, err := db.GetConnContext()
if err != nil {
badRequest(c, err)
return
}
res, err := db.TablesStats()
if err != nil {
badRequest(c, err)
return
}
format := getQueryParam(c, "format")
if format == "" {
format = "json"
}
// Save as attachment if exporting parameter is set
if getQueryParam(c, "export") == "true" {
ts := time.Now().Format(time.DateOnly)
filename := fmt.Sprintf("pgweb-dbstats-%s-%s.%s", connCtx.Database, ts, format)
c.Writer.Header().Set("Content-disposition", "attachment;filename="+filename)
}
switch format {
case "json":
c.JSON(http.StatusOK, res)
case "csv":
c.Data(http.StatusOK, "text/csv", res.CSV())
case "xml":
c.XML(200, res)
default:
badRequest(c, "invalid format")
}
}
// HandleQuery runs the database query
func HandleQuery(query string, c *gin.Context) {
metrics.IncrementQueriesCount()
@@ -549,6 +604,8 @@ func GetInfo(c *gin.Context) {
"features": gin.H{
"session_lock": command.Opts.LockSession,
"query_timeout": command.Opts.QueryTimeout,
"local_queries": QueryStore != nil,
"bookmarks_only": command.Opts.BookmarksOnly,
},
})
}
@@ -600,3 +657,78 @@ func GetFunction(c *gin.Context) {
res, err := DB(c).Function(c.Param("id"))
serveResult(c, res, err)
}
func GetLocalQueries(c *gin.Context) {
connCtx, err := DB(c).GetConnContext()
if err != nil {
badRequest(c, err)
return
}
storeQueries, err := QueryStore.ReadAll()
if err != nil {
badRequest(c, err)
return
}
queries := []localQuery{}
for _, q := range storeQueries {
if !q.IsPermitted(connCtx.Host, connCtx.User, connCtx.Database, connCtx.Mode) {
continue
}
queries = append(queries, localQuery{
ID: q.ID,
Title: q.Meta.Title,
Description: q.Meta.Description,
Query: cleanQuery(q.Data),
})
}
successResponse(c, queries)
}
func RunLocalQuery(c *gin.Context) {
query, err := QueryStore.Read(c.Param("id"))
if err != nil {
if err == queries.ErrQueryFileNotExist {
query = nil
} else {
badRequest(c, err)
return
}
}
if query == nil {
errorResponse(c, 404, "query not found")
return
}
connCtx, err := DB(c).GetConnContext()
if err != nil {
badRequest(c, err)
return
}
if !query.IsPermitted(connCtx.Host, connCtx.User, connCtx.Database, connCtx.Mode) {
errorResponse(c, 404, "query not found")
return
}
if c.Request.Method == http.MethodGet {
successResponse(c, localQuery{
ID: query.ID,
Title: query.Meta.Title,
Description: query.Meta.Description,
Query: query.Data,
})
return
}
statement := cleanQuery(query.Data)
if statement == "" {
badRequest(c, errQueryRequired)
return
}
HandleQuery(statement, c)
}
-87
View File
@@ -1,87 +0,0 @@
package api
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// Backend represents a third party configuration source
type Backend struct {
Endpoint string
Token string
PassHeaders []string
}
// BackendRequest represents a payload sent to the third-party source
type BackendRequest struct {
Resource string `json:"resource"`
Token string `json:"token"`
Headers map[string]string `json:"headers"`
}
// BackendCredential represents the third-party response
type BackendCredential struct {
DatabaseURL string `json:"database_url"`
}
// FetchCredential sends an authentication request to a third-party service
func (be Backend) FetchCredential(ctx context.Context, resource string, c *gin.Context) (*BackendCredential, error) {
logger.WithField("resource", resource).Debug("fetching database credential")
request := BackendRequest{
Resource: resource,
Token: be.Token,
Headers: map[string]string{},
}
// Pass white-listed client headers to the backend request
for _, name := range be.PassHeaders {
request.Headers[strings.ToLower(name)] = c.Request.Header.Get(name)
}
body, err := json.Marshal(request)
if err != nil {
logger.WithField("resource", resource).Error("backend request serialization error:", err)
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, be.Endpoint, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("content-type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
logger.WithField("resource", resource).Error("backend credential fetch failed:", err)
return nil, errBackendConnectError
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
err = fmt.Errorf("backend credential fetch received HTTP status code %v", resp.StatusCode)
logger.
WithField("resource", resource).
WithField("status", resp.StatusCode).
Error(err)
return nil, err
}
cred := &BackendCredential{}
if err := json.NewDecoder(resp.Body).Decode(cred); err != nil {
return nil, err
}
if cred.DatabaseURL == "" {
return nil, errConnStringRequired
}
return cred, nil
}
-2
View File
@@ -7,12 +7,10 @@ import (
var (
errNotConnected = errors.New("Not connected")
errNotPermitted = errors.New("Not permitted")
errConnStringRequired = errors.New("Connection string is required")
errInvalidConnString = errors.New("Invalid connection string")
errSessionRequired = errors.New("Session ID is required")
errSessionLocked = errors.New("Session is locked")
errURLRequired = errors.New("URL parameter is required")
errQueryRequired = errors.New("Query parameter is required")
errDatabaseNameRequired = errors.New("Database name is required")
errBackendConnectError = errors.New("Unable to connect to the auth backend")
)
+4 -3
View File
@@ -155,11 +155,12 @@ func assetContentType(name string) string {
// Send a query result to client
func serveResult(c *gin.Context, result interface{}, err interface{}) {
if err == nil {
successResponse(c, result)
} else {
if err != nil {
badRequest(c, err)
return
}
successResponse(c, result)
}
// Send successful response back to client
+11
View File
@@ -56,3 +56,14 @@ func corsMiddleware() gin.HandlerFunc {
c.Header("Access-Control-Allow-Origin", command.Opts.CorsOrigin)
}
}
func requireLocalQueries() gin.HandlerFunc {
return func(c *gin.Context) {
if QueryStore == nil {
badRequest(c, "local queries are disabled")
return
}
c.Next()
}
}
+7 -1
View File
@@ -35,6 +35,7 @@ func SetupRoutes(router *gin.Engine) {
api.POST("/switchdb", SwitchDb)
api.GET("/databases", GetDatabases)
api.GET("/connection", GetConnectionInfo)
api.GET("/server_settings", GetServerSettings)
api.GET("/activity", GetActivity)
api.GET("/schemas", GetSchemas)
api.GET("/objects", GetObjects)
@@ -43,6 +44,7 @@ func SetupRoutes(router *gin.Engine) {
api.GET("/tables/:table/info", GetTableInfo)
api.GET("/tables/:table/indexes", GetTableIndexes)
api.GET("/tables/:table/constraints", GetTableConstraints)
api.GET("/tables_stats", GetTablesStats)
api.GET("/functions/:id", GetFunction)
api.GET("/query", RunQuery)
api.POST("/query", RunQuery)
@@ -53,10 +55,14 @@ func SetupRoutes(router *gin.Engine) {
api.GET("/history", GetHistory)
api.GET("/bookmarks", GetBookmarks)
api.GET("/export", DataExport)
api.GET("/local_queries", requireLocalQueries(), GetLocalQueries)
api.GET("/local_queries/:id", requireLocalQueries(), RunLocalQuery)
api.POST("/local_queries/:id", requireLocalQueries(), RunLocalQuery)
}
func SetupMetrics(engine *gin.Engine) {
if command.Opts.MetricsEnabled && command.Opts.MetricsAddr == "" {
engine.GET("/metrics", gin.WrapH(metrics.Handler()))
// NOTE: We're not supporting the MetricsPath CLI option here to avoid the route conflicts.
engine.GET("/metrics", gin.WrapH(metrics.NewHandler()))
}
}
+11 -9
View File
@@ -43,18 +43,21 @@ func (m *SessionManager) IDs() []string {
func (m *SessionManager) Sessions() map[string]*client.Client {
m.mu.Lock()
sessions := m.sessions
m.mu.Unlock()
defer m.mu.Unlock()
sessions := make(map[string]*client.Client, len(m.sessions))
for k, v := range m.sessions {
sessions[k] = v
}
return sessions
}
func (m *SessionManager) Get(id string) *client.Client {
m.mu.Lock()
c := m.sessions[id]
m.mu.Unlock()
defer m.mu.Unlock()
return c
return m.sessions[id]
}
func (m *SessionManager) Add(id string, conn *client.Client) {
@@ -81,10 +84,9 @@ func (m *SessionManager) Remove(id string) bool {
func (m *SessionManager) Len() int {
m.mu.Lock()
sz := len(m.sessions)
m.mu.Unlock()
defer m.mu.Unlock()
return sz
return len(m.sessions)
}
func (m *SessionManager) Cleanup() int {
@@ -118,7 +120,7 @@ func (m *SessionManager) RunPeriodicCleanup() {
}
func (m *SessionManager) staleSessions() []string {
m.mu.TryLock()
m.mu.Lock()
defer m.mu.Unlock()
now := time.Now()
+8
View File
@@ -0,0 +1,8 @@
package api
type localQuery struct {
ID string `json:"id"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Query string `json:"query"`
}
+18 -2
View File
@@ -1,6 +1,8 @@
package bookmarks
import (
"os"
"github.com/sosedoff/pgweb/pkg/command"
"github.com/sosedoff/pgweb/pkg/shared"
)
@@ -12,10 +14,13 @@ type Bookmark struct {
Host string // Server hostname
Port int // Server port
User string // Database user
UserVar string // Database user environment variable
Password string // User password
PasswordVar string // User password environment variable
Database string // Database name
SSLMode string // Connection SSL mode
SSH *shared.SSHInfo // SSH tunnel config
ReadOnly bool // Enable read-only transaction mode
}
// SSHInfoIsEmpty returns true if ssh configuration is not provided
@@ -25,13 +30,24 @@ func (b Bookmark) SSHInfoIsEmpty() bool {
// ConvertToOptions returns an options struct from connection details
func (b Bookmark) ConvertToOptions() command.Options {
user := b.User
if b.User == "" {
user = os.Getenv(b.UserVar)
}
pass := b.Password
if b.Password == "" {
pass = os.Getenv(b.PasswordVar)
}
return command.Options{
URL: b.URL,
Host: b.Host,
Port: b.Port,
User: b.User,
Pass: b.Password,
User: user,
Pass: pass,
DbName: b.Database,
SSLMode: b.SSLMode,
ReadOnly: b.ReadOnly,
}
}
+61
View File
@@ -35,6 +35,65 @@ func TestBookmarkSSHInfoIsEmpty(t *testing.T) {
})
}
func TestBookmarkWithVarsConvertToOptions(t *testing.T) {
t.Run("literals set", func(t *testing.T) {
b := Bookmark{
User: "user",
UserVar: "",
Password: "password",
PasswordVar: "",
}
expOpt := command.Options{
User: "user",
Pass: "password",
}
opt := b.ConvertToOptions()
assert.Equal(t, expOpt, opt)
})
t.Run("all set", func(t *testing.T) {
b := Bookmark{
User: "user",
UserVar: "DB_USER",
Password: "password",
PasswordVar: "DB_PASSWORD",
}
expOpt := command.Options{
User: "user",
Pass: "password",
}
t.Setenv("DB_USER", "user123")
t.Setenv("DB_PASSWORD", "password123")
opt := b.ConvertToOptions()
assert.Equal(t, expOpt, opt)
})
t.Run("env vars set", func(t *testing.T) {
b := Bookmark{
User: "",
UserVar: "DB_USER",
Password: "",
PasswordVar: "DB_PASSWORD",
}
expOpt := command.Options{
User: "user123",
Pass: "password123",
}
t.Setenv("DB_USER", "user123")
t.Setenv("DB_PASSWORD", "password123")
opt := b.ConvertToOptions()
assert.Equal(t, expOpt, opt)
})
}
func TestBookmarkConvertToOptions(t *testing.T) {
b := Bookmark{
URL: "postgres://username:password@host:port/database?sslmode=disable",
@@ -44,6 +103,7 @@ func TestBookmarkConvertToOptions(t *testing.T) {
Password: "password",
Database: "mydatabase",
SSLMode: "disable",
ReadOnly: true,
}
expOpt := command.Options{
@@ -54,6 +114,7 @@ func TestBookmarkConvertToOptions(t *testing.T) {
Pass: "password",
DbName: "mydatabase",
SSLMode: "disable",
ReadOnly: true,
}
opt := b.ConvertToOptions()
+20 -2
View File
@@ -12,7 +12,7 @@ func TestManagerList(t *testing.T) {
num int
err string
}{
{"../../data", 3, ""},
{"../../data", 4, ""},
{"../../data/bookmark.toml", 0, "is not a directory"},
{"../../data2", 0, ""},
{"", 0, ""},
@@ -32,7 +32,12 @@ func TestManagerList(t *testing.T) {
func TestManagerListIDs(t *testing.T) {
ids, err := NewManager("../../data").ListIDs()
assert.NoError(t, err)
assert.Equal(t, []string{"bookmark", "bookmark_invalid_ssl", "bookmark_url"}, ids)
assert.Equal(t, []string{
"bookmark",
"bookmark_invalid_ssl",
"bookmark_url",
"bookmark_with_ssh",
}, ids)
}
func TestManagerGet(t *testing.T) {
@@ -80,6 +85,19 @@ func Test_readBookmark(t *testing.T) {
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)
+99 -47
View File
@@ -1,11 +1,12 @@
package cli
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"os/signal"
"regexp"
"strings"
"syscall"
"time"
@@ -20,7 +21,9 @@ import (
"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 (
@@ -28,14 +31,11 @@ var (
options command.Options
readonlyWarning = `
------------------------------------------------------
SECURITY WARNING: You are running pgweb in read-only mode.
This mode is designed for environments where users could potentially delete / change data.
For proper read-only access please follow postgresql role management documentation.
------------------------------------------------------`
regexErrConnectionRefused = regexp.MustCompile(`(connection|actively) refused`)
regexErrAuthFailed = regexp.MustCompile(`authentication failed`)
--------------------------------------------------------------------------------
SECURITY WARNING: You are running Pgweb in read-only mode.
This mode is designed for environments where users could potentially delete or change data.
For proper read-only access please follow PostgreSQL role management documentation.
--------------------------------------------------------------------------------`
)
func init() {
@@ -75,35 +75,20 @@ func initClient() {
}
if command.Opts.Debug {
fmt.Println("Server connection string:", cl.ConnectionString)
fmt.Println("Opening database connection using string:", cl.ConnectionString)
}
retryCount := command.Opts.RetryCount
retryDelay := time.Second * time.Duration(command.Opts.RetryDelay)
fmt.Println("Connecting to server...")
if err := cl.Test(); err != nil {
msg := err.Error()
// Check if we're trying to connect to the default database.
if command.Opts.DbName == "" && command.Opts.URL == "" {
// If database does not exist, allow user to connect from the UI.
if strings.Contains(msg, "database") && strings.Contains(msg, "does not exist") {
fmt.Println("Error:", msg)
abort, err := testClient(cl, int(retryCount), retryDelay)
if err != nil {
if abort {
exitWithMessage(err.Error())
} else {
return
}
// Do not bail if local server is not running.
if regexErrConnectionRefused.MatchString(msg) {
fmt.Println("Error:", msg)
return
}
// Do not bail if local auth is invalid
if regexErrAuthFailed.MatchString(msg) {
fmt.Println("Error:", msg)
return
}
}
exitWithMessage(msg)
}
if !command.Opts.Sessions {
@@ -157,9 +142,33 @@ func initOptions() {
}
}
configureLocalQueryStore()
printVersion()
}
func configureLocalQueryStore() {
if options.Sessions || options.QueriesDir == "" {
return
}
stat, err := os.Stat(options.QueriesDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
logger.Debugf("local queries directory %q does not exist, disabling feature", options.QueriesDir)
} else {
logger.Debugf("local queries feature disabled due to error: %v", err)
}
return
}
if !stat.IsDir() {
logger.Debugf("local queries path %q is not a directory", options.QueriesDir)
return
}
api.QueryStore = queries.NewStore(options.QueriesDir)
}
func configureLogger(opts command.Options) error {
if options.Debug {
logger.SetLevel(logrus.DebugLevel)
@@ -187,7 +196,7 @@ func printVersion() {
fmt.Println(command.VersionString())
}
func startServer() {
func startServer() *anyhttp.ServerCtx {
router := gin.New()
router.Use(api.RequestLogger(logger))
router.Use(gin.Recovery())
@@ -203,18 +212,22 @@ func startServer() {
api.SetupMetrics(router)
fmt.Println("Starting server...")
go func() {
metrics.SetHealty(true)
address := fmt.Sprintf("%v:%v", options.HTTPHost, options.HTTPPort)
if options.HTTPAddr != "" {
address = options.HTTPAddr
}
err := router.Run(fmt.Sprintf("%v:%v", options.HTTPHost, options.HTTPPort))
metrics.SetHealthy(true)
ctx, err := anyhttp.Serve(address, router.Handler())
if err != nil {
fmt.Println("Cant start server:", err)
fmt.Println("Can't start server:", err)
if strings.Contains(err.Error(), "address already in use") {
openPage()
}
os.Exit(1)
}
}()
return ctx
}
func startMetricsServer() {
@@ -229,12 +242,6 @@ func startMetricsServer() {
}
}
func handleSignals() {
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
<-c
}
func openPage() {
url := fmt.Sprintf("http://%v:%v/%s", options.HTTPHost, options.HTTPPort, options.Prefix)
fmt.Println("To view database open", url, "in browser")
@@ -254,6 +261,39 @@ func openPage() {
}
}
// testClient attempts to establish a database connection until it succeeds or
// give up after certain number of retries. Retries only available when database
// name or a connection string is provided.
func testClient(cl *client.Client, retryCount int, retryDelay time.Duration) (abort bool, err error) {
usingDefaultDB := command.Opts.DbName == "" && command.Opts.URL == ""
for {
err = cl.Test()
if err == nil {
return false, nil
}
// Continue normal start up if can't connect locally without database details.
if usingDefaultDB {
if errors.Is(err, client.ErrConnectionRefused) ||
errors.Is(err, client.ErrAuthFailed) ||
errors.Is(err, client.ErrDatabaseNotExist) {
return false, err
}
}
// Only retry if can't establish connection to the server.
if errors.Is(err, client.ErrConnectionRefused) && retryCount > 0 {
fmt.Printf("Connection error: %v, retrying in %v (%d remaining)\n", err, retryDelay, retryCount)
retryCount--
<-time.After(retryDelay)
continue
}
return true, err
}
}
func Run() {
initOptions()
initClient()
@@ -281,11 +321,23 @@ func Run() {
}
}
// 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()
}
startServer()
serverCtx := startServer()
openPage()
handleSignals()
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
select {
case doneErr := <-serverCtx.Done:
logger.Infoln("idle server. shutting down error: ", doneErr)
case <-c:
logger.Infoln("received signal. shutting down")
serverCtx.Shutdown(context.TODO())
}
}
+111 -6
View File
@@ -7,6 +7,7 @@ import (
"log"
neturl "net/url"
"reflect"
"regexp"
"strings"
"time"
@@ -21,6 +22,18 @@ import (
"github.com/sosedoff/pgweb/pkg/statements"
)
var (
regexErrAuthFailed = regexp.MustCompile(`(authentication failed|role "(.*)" does not exist)`)
regexErrConnectionRefused = regexp.MustCompile(`(connection|actively) refused`)
regexErrDatabaseNotExist = regexp.MustCompile(`database "(.*)" does not exist`)
)
var (
ErrAuthFailed = errors.New("authentication failed")
ErrConnectionRefused = errors.New("connection refused")
ErrDatabaseNotExist = errors.New("database does not exist")
)
type Client struct {
db *sqlx.DB
tunnel *Tunnel
@@ -28,6 +41,7 @@ type Client struct {
serverType string
lastQueryTime time.Time
queryTimeout time.Duration
readonly bool
closed bool
External bool `json:"external"`
History []history.Record `json:"history"`
@@ -69,7 +83,10 @@ func New() (*Client, error) {
}
func NewFromUrl(url string, sshInfo *shared.SSHInfo) (*Client, error) {
var tunnel *Tunnel
var (
tunnel *Tunnel
err error
)
if sshInfo != nil {
if command.Opts.DisableSSH {
@@ -79,7 +96,7 @@ func NewFromUrl(url string, sshInfo *shared.SSHInfo) (*Client, error) {
fmt.Println("Opening SSH tunnel for:", sshInfo)
}
tunnel, err := NewTunnel(sshInfo, url)
tunnel, err = NewTunnel(sshInfo, url)
if err != nil {
tunnel.Close()
return nil, err
@@ -150,7 +167,16 @@ func NewFromBookmark(bookmark *bookmarks.Bookmark) (*Client, error) {
sshInfo = bookmark.SSH
}
return NewFromUrl(connStr, sshInfo)
client, err := NewFromUrl(connStr, sshInfo)
if err != nil {
return nil, err
}
if bookmark.ReadOnly {
client.readonly = true
}
return client, nil
}
func (client *Client) init() {
@@ -176,7 +202,28 @@ func (client *Client) setServerVersion() {
}
func (client *Client) Test() error {
return client.db.Ping()
// NOTE: This is a different timeout defined in CLI OpenTimeout
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
err := client.db.PingContext(ctx)
if err == nil {
return nil
}
errMsg := err.Error()
if regexErrConnectionRefused.MatchString(errMsg) {
return ErrConnectionRefused
}
if regexErrAuthFailed.MatchString(errMsg) {
return ErrAuthFailed
}
if regexErrDatabaseNotExist.MatchString(errMsg) {
return ErrDatabaseNotExist
}
return err
}
func (client *Client) TestWithTimeout(timeout time.Duration) (result error) {
@@ -206,7 +253,15 @@ func (client *Client) TestWithTimeout(timeout time.Duration) (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) {
@@ -327,6 +382,14 @@ func (client *Client) TableConstraints(table string) (*Result, error) {
return res, err
}
func (client *Client) TablesStats() (*Result, error) {
return client.query(statements.TablesStats)
}
func (client *Client) ServerSettings() (*Result, error) {
return client.query(statements.Settings)
}
// Returns all active queriers on the server
func (client *Client) Activity() (*Result, error) {
if client.serverType == cockroachType {
@@ -427,7 +490,7 @@ func (client *Client) query(query string, args ...interface{}) (*Result, error)
// We're going to force-set transaction mode on every query.
// This is needed so that default mode could not be changed by user.
if command.Opts.ReadOnly {
if command.Opts.ReadOnly || client.readonly {
if err := client.SetReadOnlyMode(); err != nil {
return nil, err
}
@@ -512,6 +575,7 @@ func (client *Client) Close() error {
}
defer func() {
client.closed = true
client.tunnel = nil
}()
if client.tunnel != nil {
@@ -573,3 +637,44 @@ func (client *Client) hasHistoryRecord(query string) bool {
return result
}
type ConnContext struct {
Host string
User string
Database string
Mode string
}
func (c ConnContext) String() string {
return fmt.Sprintf(
"host=%q user=%q database=%q mode=%q",
c.Host, c.User, c.Database, c.Mode,
)
}
// ConnContext returns information about current database connection
func (client *Client) GetConnContext() (*ConnContext, error) {
url, err := neturl.Parse(client.ConnectionString)
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
connCtx := ConnContext{
Host: url.Hostname(),
Mode: "default",
}
if command.Opts.ReadOnly {
connCtx.Mode = "readonly"
}
row := client.db.QueryRowContext(ctx, "SELECT current_user, current_database()")
if err := row.Scan(&connCtx.User, &connCtx.Database); err != nil {
return nil, err
}
return &connCtx, nil
}
+147 -4
View File
@@ -11,9 +11,10 @@ import (
"testing"
"time"
"github.com/sosedoff/pgweb/pkg/command"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/sosedoff/pgweb/pkg/command"
)
var (
@@ -56,7 +57,9 @@ func assertMatches(t *testing.T, expected, src []string) {
func pgVersion() (int, int) {
var major, minor int
fmt.Sscanf(os.Getenv("PGVERSION"), "%d.%d", &major, &minor)
if _, err := fmt.Sscanf(os.Getenv("PGVERSION"), "%d.%d", &major, &minor); err != nil {
log.Println("[warn] unable to read value of PGVERSION env var")
}
return major, minor
}
@@ -199,10 +202,50 @@ func testClientIdleTime(t *testing.T) {
}
func testTest(t *testing.T) {
assert.NoError(t, testClient.Test())
examples := []struct {
name string
input string
err error
}{
{
name: "success",
input: fmt.Sprintf("postgres://%s@%s:%s/%s?sslmode=disable", serverUser, serverHost, serverPort, serverDatabase),
err: nil,
},
{
name: "connection refused",
input: "postgresql://localhost:5433/dbname",
err: ErrConnectionRefused,
},
{
name: "invalid user",
input: fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable", "foo", serverPassword, serverHost, serverPort, serverDatabase),
err: ErrAuthFailed,
},
{
name: "invalid password",
input: fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable", serverUser, "foo", serverHost, serverPort, serverDatabase),
err: ErrAuthFailed,
},
{
name: "invalid database",
input: fmt.Sprintf("postgres://%s@%s:%s/%s?sslmode=disable", serverUser, serverHost, serverPort, "foo"),
err: ErrDatabaseNotExist,
},
}
for _, ex := range examples {
t.Run(ex.name, func(t *testing.T) {
conn, err := NewFromUrl(ex.input, nil)
require.NoError(t, err)
require.Equal(t, ex.err, conn.Test())
})
}
}
func testInfo(t *testing.T) {
t.Run("normal", func(t *testing.T) {
expected := []string{
"session_user",
"current_user",
@@ -218,6 +261,41 @@ func testInfo(t *testing.T) {
res, err := testClient.Info()
assert.NoError(t, err)
assert.Equal(t, expected, res.Columns)
})
t.Run("with restrictions", func(t *testing.T) {
expected := []string{
"session_user",
"current_user",
"current_database",
"current_schemas",
"version",
}
// Prepare a new user and database
testClient.db.MustExec("DROP DATABASE IF EXISTS testdb")
testClient.db.Exec("DROP OWNED BY IF EXISTS testuser") //nolint:all
testClient.db.MustExec("DROP ROLE IF EXISTS testuser")
testClient.db.MustExec("CREATE ROLE testuser WITH PASSWORD 'secret' LOGIN NOSUPERUSER NOINHERIT")
testClient.db.MustExec("CREATE DATABASE testdb OWNER testuser")
// Disable access to inet_ calls for new user
url := fmt.Sprintf("postgres://%s:@%s:%s/testdb?sslmode=disable", serverUser, serverHost, serverPort)
client, err := NewFromUrl(url, nil)
assert.NoError(t, err)
client.db.MustExec("REVOKE EXECUTE ON FUNCTION inet_client_addr() FROM PUBLIC")
assert.NoError(t, client.Close())
// Connect using new user
url = fmt.Sprintf("postgres://testuser:secret@%s:%s/testdb?sslmode=disable", serverHost, serverPort)
client, err = NewFromUrl(url, nil)
assert.NoError(t, err)
defer client.Close()
res, err := client.Info()
assert.NoError(t, err)
assert.Equal(t, expected, res.Columns)
})
}
func testActivity(t *testing.T) {
@@ -603,6 +681,68 @@ func testReadOnlyMode(t *testing.T) {
_, err = client.Query("/* CREATE TABLE foobar(id integer); */ SELECT 'foo';")
assert.NoError(t, err)
t.Run("with local readonly flag", func(t *testing.T) {
command.Opts.ReadOnly = false
client.readonly = true
_, err := client.Query("INSERT INTO foobar(id) VALUES(1)")
assert.Error(t, err, "query contains keywords not allowed in read-only mode")
})
}
func testTablesStats(t *testing.T) {
columns := []string{
"schema_name",
"table_name",
"total_size",
"data_size",
"index_size",
"estimated_rows_count",
"estimated_rows",
"index_to_data_ratio",
"indexes_count",
"columns_count",
}
result, err := testClient.TablesStats()
assert.NoError(t, err)
assert.Equal(t, columns, result.Columns)
}
func testConnContext(t *testing.T) {
result, err := testClient.GetConnContext()
assert.NoError(t, err)
assert.Equal(t, "localhost", result.Host)
assert.Equal(t, "postgres", result.User)
assert.Equal(t, "booktown", result.Database)
assert.Equal(t, "default", result.Mode)
}
func testServerSettings(t *testing.T) {
expectedColumns := []string{
"name",
"setting",
"unit",
"category",
"short_desc",
"extra_desc",
"context",
"vartype",
"source",
"min_val",
"max_val",
"enumvals",
"boot_val",
"reset_val",
"sourcefile",
"sourceline",
"pending_restart",
}
result, err := testClient.ServerSettings()
assert.NoError(t, err)
assert.Equal(t, expectedColumns, result.Columns)
}
func TestAll(t *testing.T) {
@@ -642,6 +782,9 @@ func TestAll(t *testing.T) {
testHistory(t)
testReadOnlyMode(t)
testDumpExport(t)
testTablesStats(t)
testConnContext(t)
testServerSettings(t)
teardownClient()
teardown(t, true)
+1 -1
View File
@@ -16,7 +16,7 @@ const (
)
var (
// BinaryEncodingFormat specifies the default serialization format of binary data
// BinaryCodec sets the serialization format of binary data
BinaryCodec = CodecBase64
)
+9 -8
View File
@@ -8,6 +8,7 @@ import (
"net"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
@@ -35,8 +36,8 @@ type Tunnel struct {
Listener *net.TCPListener
}
func privateKeyPath() string {
return os.Getenv("HOME") + "/.ssh/id_rsa"
func defaultKeyPath() string {
return filepath.Join(os.Getenv("HOME"), ".ssh/id_rsa")
}
func expandKeyPath(path string) string {
@@ -61,7 +62,7 @@ func parsePrivateKey(keyPath string, keyPass string) (ssh.Signer, error) {
signer, err := ssh.ParsePrivateKey(buff)
if _, ok := err.(*ssh.PassphraseMissingError); ok {
if keyPass == "" {
return nil, errors.New("SSH key password is not provided")
return nil, errors.New("ssh key password is not provided")
}
return sshkeys.ParseEncryptedPrivateKey(buff, []byte(keyPass))
}
@@ -75,13 +76,13 @@ func makeConfig(info *shared.SSHInfo) (*ssh.ClientConfig, error) {
// Try to use user-provided key, fallback to system default key
keyPath := info.Key
if keyPath == "" {
keyPath = privateKeyPath()
keyPath = defaultKeyPath()
} else {
keyPath = expandKeyPath(keyPath)
}
if !fileExists(keyPath) {
return nil, errors.New("ssh public key not found at " + keyPath)
return nil, fmt.Errorf("ssh public key not found at path %q", keyPath)
}
// Append public key authentication method
@@ -129,11 +130,11 @@ func (tunnel *Tunnel) handleConnection(local net.Conn) {
return
}
wg := sync.WaitGroup{}
wg := &sync.WaitGroup{}
wg.Add(2)
go tunnel.copy(&wg, local, remote)
go tunnel.copy(&wg, remote, local)
go tunnel.copy(wg, local, remote)
go tunnel.copy(wg, remote, local)
wg.Wait()
local.Close()
+3 -3
View File
@@ -8,7 +8,7 @@ import (
var (
// List of keywords that are not allowed in read-only mode
reRestrictedKeywords = regexp.MustCompile(`(?mi)\s?(CREATE|INSERT|DROP|DELETE|TRUNCATE|GRANT|OPEN|IMPORT|COPY)\s`)
reRestrictedKeywords = regexp.MustCompile(`(?mi)\s?(CREATE|INSERT|UPDATE|DROP|DELETE|TRUNCATE|GRANT|OPEN|IMPORT|COPY)\s`)
// Comment regular expressions
reSlashComment = regexp.MustCompile(`(?m)/\*.+\*/`)
@@ -28,9 +28,9 @@ var (
// Example: 10.2.3.1 -> 10.2
func getMajorMinorVersion(str string) (major int, minor int) {
chunks := strings.Split(str, ".")
fmt.Sscanf(chunks[0], "%d", &major)
fmt.Sscanf(chunks[0], "%d", &major) //nolint
if len(chunks) > 1 {
fmt.Sscanf(chunks[1], "%d", &minor)
fmt.Sscanf(chunks[1], "%d", &minor) //nolint
}
return
}
+36 -6
View File
@@ -36,7 +36,10 @@ type Options struct {
SSLRootCert string `long:"ssl-rootcert" description:"SSL certificate authority file"`
SSLCert string `long:"ssl-cert" description:"SSL client certificate file"`
SSLKey string `long:"ssl-key" description:"SSL client certificate key file"`
OpenTimeout int `long:"open-timeout" description:" Maximum wait for connection, in seconds" default:"30"`
OpenTimeout int `long:"open-timeout" description:"Maximum wait time for connection, in seconds" default:"30"`
RetryDelay uint `long:"open-retry-delay" description:"Number of seconds to wait before retrying the connection" default:"3"`
RetryCount uint `long:"open-retry" description:"Number of times to retry establishing connection" default:"0"`
HTTPAddr string `long:"address" description:"anyhttp address. See https://pkg.go.dev/go.balki.me/anyhttp#readme-address-syntax" default:""`
HTTPHost string `long:"bind" description:"HTTP server host" default:"localhost"`
HTTPPort uint `long:"listen" description:"HTTP server listen port" default:"8081"`
AuthUser string `long:"auth-user" description:"HTTP basic auth user"`
@@ -48,6 +51,8 @@ type Options struct {
LockSession bool `long:"lock-session" description:"Lock session to a single database connection"`
Bookmark string `short:"b" long:"bookmark" description:"Bookmark to use for connection. Bookmark files are stored under $HOME/.pgweb/bookmarks/*.toml" default:""`
BookmarksDir string `long:"bookmarks-dir" description:"Overrides default directory for bookmark files to search" default:""`
BookmarksOnly bool `long:"bookmarks-only" description:"Allow only connections from bookmarks"`
QueriesDir string `long:"queries-dir" description:"Overrides default directory for local queries"`
DisablePrettyJSON bool `long:"no-pretty-json" description:"Disable JSON formatting feature for result export"`
DisableSSH bool `long:"no-ssh" description:"Disable database connections via SSH"`
ConnectBackend string `long:"connect-backend" description:"Enable database authentication through a third party backend"`
@@ -115,6 +120,10 @@ func ParseOptions(args []string) (Options, error) {
}
}
if getPrefixedEnvVar("BOOKMARKS_ONLY") != "" {
opts.BookmarksOnly = true
}
if getPrefixedEnvVar("SESSIONS") != "" {
opts.Sessions = true
}
@@ -134,7 +143,7 @@ func ParseOptions(args []string) (Options, error) {
opts.SSLMode = ""
}
if opts.Prefix != "" && !strings.Contains(opts.Prefix, "/") {
if opts.Prefix != "" && !strings.HasSuffix(opts.Prefix, "/") {
opts.Prefix = opts.Prefix + "/"
}
@@ -159,10 +168,31 @@ func ParseOptions(args []string) (Options, error) {
}
}
if opts.BookmarksOnly {
if opts.URL != "" {
return opts, errors.New("--url not supported in bookmarks-only mode")
}
if opts.Host != "" && opts.Host != "localhost" {
return opts, errors.New("--host not supported in bookmarks-only mode")
}
if opts.ConnectBackend != "" {
return opts, errors.New("--connect-backend not supported in bookmarks-only mode")
}
}
homePath, err := homedir.Dir()
if err != nil {
fmt.Fprintf(os.Stderr, "[WARN] can't detect home dir: %v", err)
homePath = os.Getenv("HOME")
}
if homePath != "" {
if opts.BookmarksDir == "" {
path, err := homedir.Dir()
if err == nil {
opts.BookmarksDir = filepath.Join(path, ".pgweb/bookmarks")
opts.BookmarksDir = filepath.Join(homePath, ".pgweb/bookmarks")
}
if opts.QueriesDir == "" {
opts.QueriesDir = filepath.Join(homePath, ".pgweb/queries")
}
}
@@ -208,7 +238,7 @@ func AvailableEnvVars() string {
return strings.Join([]string{
" " + envVarPrefix + "DATABASE_URL Database connection string",
" " + envVarPrefix + "URL_PREFIX HTTP server path prefix",
" " + envVarPrefix + "SESSIONS: Enable multiple database sessions",
" " + envVarPrefix + "SESSIONS Enable multiple database sessions",
" " + envVarPrefix + "LOCK_SESSION Lock session to a single database connection",
" " + envVarPrefix + "AUTH_USER HTTP basic auth username",
" " + envVarPrefix + "AUTH_PASS HTTP basic auth password",
+14
View File
@@ -80,4 +80,18 @@ func TestParseOptions(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, "../../data/passfile", opts.Passfile)
})
t.Run("bookmarks only mode", func(t *testing.T) {
_, err := ParseOptions([]string{"--bookmarks-only"})
assert.NoError(t, err)
_, err = ParseOptions([]string{"--bookmarks-only", "--url", "test"})
assert.EqualError(t, err, "--url not supported in bookmarks-only mode")
_, err = ParseOptions([]string{"--bookmarks-only", "--host", "test", "--port", "5432"})
assert.EqualError(t, err, "--host not supported in bookmarks-only mode")
_, err = ParseOptions([]string{"--bookmarks-only", "--connect-backend", "test", "--sessions", "--connect-token", "token", "--url", "127.0.0.2"})
assert.EqualError(t, err, "--connect-backend not supported in bookmarks-only mode")
})
}
+1 -1
View File
@@ -8,7 +8,7 @@ import (
const (
// Version is the current Pgweb application version
Version = "0.13.0"
Version = "0.17.0"
)
var (
+92
View File
@@ -0,0 +1,92 @@
package connect
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/sirupsen/logrus"
)
type Backend struct {
Endpoint string
Token string
PassHeaders []string
logger *logrus.Logger
}
func NewBackend(endpoint string, token string) Backend {
return Backend{
Endpoint: endpoint,
Token: token,
logger: logrus.StandardLogger(),
}
}
func (be *Backend) SetLogger(logger *logrus.Logger) {
be.logger = logger
}
func (be *Backend) SetPassHeaders(headers []string) {
be.PassHeaders = headers
}
func (be *Backend) FetchCredential(ctx context.Context, resource string, headers http.Header) (*Credential, error) {
be.logger.WithField("resource", resource).Debug("fetching database credential")
request := Request{
Resource: resource,
Token: be.Token,
Headers: map[string]string{},
}
// Pass allow-listed client headers to the backend request
for _, name := range be.PassHeaders {
request.Headers[strings.ToLower(name)] = headers.Get(name)
}
body, err := json.Marshal(request)
if err != nil {
be.logger.WithField("resource", resource).Error("backend request serialization error:", err)
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, be.Endpoint, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("content-type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
be.logger.WithField("resource", resource).Error("backend credential fetch failed:", err)
return nil, errBackendConnectError
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
err = fmt.Errorf("backend credential fetch received HTTP status code %v", resp.StatusCode)
be.logger.
WithField("resource", request.Resource).
WithField("status", resp.StatusCode).
Error(err)
return nil, err
}
cred := &Credential{}
if err := json.NewDecoder(resp.Body).Decode(cred); err != nil {
return nil, err
}
if cred.DatabaseURL == "" {
return nil, errConnStringRequired
}
return cred, nil
}
@@ -1,4 +1,4 @@
package api
package connect
import (
"context"
@@ -9,6 +9,7 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
)
@@ -17,8 +18,8 @@ func TestBackendFetchCredential(t *testing.T) {
name string
backend Backend
resourceName string
cred *BackendCredential
reqCtx *gin.Context
cred *Credential
headers http.Header
ctx func() (context.Context, context.CancelFunc)
err error
}{
@@ -33,12 +34,12 @@ func TestBackendFetchCredential(t *testing.T) {
ctx: func() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), time.Millisecond*100)
},
err: errors.New("Unable to connect to the auth backend"),
err: errors.New("unable to connect to the auth backend"),
},
{
name: "Empty response",
backend: Backend{Endpoint: "http://localhost:5555/empty-response"},
err: errors.New("Connection string is required"),
err: errors.New("connection string is required"),
},
{
name: "Missing header",
@@ -51,19 +52,15 @@ func TestBackendFetchCredential(t *testing.T) {
Endpoint: "http://localhost:5555/pass-header",
PassHeaders: []string{"x-foo"},
},
reqCtx: &gin.Context{
Request: &http.Request{
Header: http.Header{
headers: http.Header{
"X-Foo": []string{"bar"},
},
},
},
cred: &BackendCredential{DatabaseURL: "postgres://hostname/bar"},
cred: &Credential{DatabaseURL: "postgres://hostname/bar"},
},
{
name: "Success",
backend: Backend{Endpoint: "http://localhost:5555/success"},
cred: &BackendCredential{DatabaseURL: "postgres://hostname/dbname"},
cred: &Credential{DatabaseURL: "postgres://hostname/dbname"},
},
}
@@ -73,6 +70,8 @@ func TestBackendFetchCredential(t *testing.T) {
startTestBackend(srvCtx, "localhost:5555")
for _, ex := range examples {
ex.backend.logger = logrus.StandardLogger()
t.Run(ex.name, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
if ex.ctx != nil {
@@ -80,14 +79,7 @@ func TestBackendFetchCredential(t *testing.T) {
}
defer cancel()
reqCtx := ex.reqCtx
if reqCtx == nil {
reqCtx = &gin.Context{
Request: &http.Request{},
}
}
cred, err := ex.backend.FetchCredential(ctx, ex.resourceName, reqCtx)
cred, err := ex.backend.FetchCredential(ctx, ex.resourceName, ex.headers)
assert.Equal(t, ex.err, err)
assert.Equal(t, ex.cred, cred)
})
@@ -117,7 +109,7 @@ func startTestBackend(ctx context.Context, listenAddr string) {
})
router.POST("/pass-header", func(c *gin.Context) {
req := BackendRequest{}
req := Request{}
if err := c.BindJSON(&req); err != nil {
panic(err)
}
+20
View File
@@ -0,0 +1,20 @@
package connect
import "errors"
var (
errBackendConnectError = errors.New("unable to connect to the auth backend")
errConnStringRequired = errors.New("connection string is required")
)
// Request holds the resource request details
type Request struct {
Resource string `json:"resource"`
Token string `json:"token"`
Headers map[string]string `json:"headers,omitempty"`
}
// Credential holds the database connection string
type Credential struct {
DatabaseURL string `json:"database_url"`
}
+1 -1
View File
@@ -178,7 +178,7 @@ func lookupPassword(opts command.Options, url *neturl.URL) string {
if url != nil {
var dbName string
fmt.Sscanf(url.Path, "/%s", &dbName)
fmt.Sscanf(url.Path, "/%s", &dbName) //nolint
return passfile.FindPassword(
url.Hostname(),
+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(),
}
}
+20 -4
View File
@@ -1,6 +1,8 @@
package metrics
import (
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
@@ -16,12 +18,26 @@ var (
Help: "Total number of custom queries executed",
})
healtyGauge = promauto.NewGauge(prometheus.GaugeOpts{
Name: "pgweb_healty",
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()
}
@@ -30,10 +46,10 @@ func SetSessionsCount(val int) {
sessionsGauge.Set(float64(val))
}
func SetHealty(val bool) {
func SetHealthy(val bool) {
healthy := 0.0
if val {
healthy = 1.0
}
healtyGauge.Set(float64(healthy))
healthyGauge.Set(float64(healthy))
}
+1 -6
View File
@@ -3,17 +3,12 @@ package metrics
import (
"net/http"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/sirupsen/logrus"
)
func Handler() http.Handler {
return promhttp.Handler()
}
func StartServer(logger *logrus.Logger, path string, addr string) error {
logger.WithField("addr", addr).WithField("path", path).Info("starting prometheus metrics server")
http.Handle(path, Handler())
http.Handle(path, NewHandler())
return http.ListenAndServe(addr, nil)
}
+43
View File
@@ -0,0 +1,43 @@
package queries
import (
"fmt"
"regexp"
"strings"
)
type field struct {
value string
re *regexp.Regexp
}
func (f field) String() string {
return f.value
}
func (f field) matches(input string) bool {
if f.re != nil {
return f.re.MatchString(input)
}
return f.value == input
}
func newField(value string) (field, error) {
f := field{value: value}
if value == "*" { // match everything
f.re = reMatchAll
} else if reExpression.MatchString(value) { // match by given expression
// Make writing expressions easier for values like "foo_*"
if strings.Count(value, "*") == 1 {
value = strings.Replace(value, "*", "(.+)", 1)
}
re, err := regexp.Compile(fmt.Sprintf("^%s$", value))
if err != nil {
return f, err
}
f.re = re
}
return f, nil
}
+41
View File
@@ -0,0 +1,41 @@
package queries
import (
"testing"
"github.com/stretchr/testify/assert"
)
func Test_field(t *testing.T) {
field, err := newField("val")
assert.NoError(t, err)
assert.Equal(t, "val", field.value)
assert.Equal(t, true, field.matches("val"))
assert.Equal(t, false, field.matches("value"))
field, err = newField("*")
assert.NoError(t, err)
assert.Equal(t, "*", field.value)
assert.NotNil(t, field.re)
assert.Equal(t, true, field.matches("val"))
assert.Equal(t, true, field.matches("value"))
field, err = newField("(.+")
assert.EqualError(t, err, "error parsing regexp: missing closing ): `^(.+$`")
assert.NotNil(t, field)
field, err = newField("foo_*")
assert.NoError(t, err)
assert.Equal(t, "foo_*", field.value)
assert.NotNil(t, field.re)
assert.Equal(t, false, field.matches("foo"))
assert.Equal(t, true, field.matches("foo_bar"))
assert.Equal(t, true, field.matches("foo_bar_widget"))
}
func Test_fieldString(t *testing.T) {
field, err := newField("val")
assert.NoError(t, err)
assert.Equal(t, "val", field.String())
}
+148
View File
@@ -0,0 +1,148 @@
package queries
import (
"fmt"
"regexp"
"strconv"
"strings"
"time"
)
var (
reMetaPrefix = regexp.MustCompile(`(?m)^\s*--\s*pgweb:\s*(.+)`)
reMetaContent = regexp.MustCompile(`([\w]+)\s*=\s*"([^"]+)"`)
reMatchAll = regexp.MustCompile(`^(.+)$`)
reExpression = regexp.MustCompile(`[\[\]\(\)\+\*]+`)
allowedKeys = []string{"title", "description", "host", "user", "database", "mode", "timeout"}
allowedModes = map[string]bool{"readonly": true, "*": true}
)
type Metadata struct {
Title string
Description string
Host field
User field
Database field
Mode field
Timeout *time.Duration
}
func parseMetadata(input string) (*Metadata, error) {
fields, err := parseFields(input)
if err != nil {
return nil, err
}
if fields == nil {
return nil, nil
}
// Host must be set to limit queries availability
if fields["host"] == "" {
return nil, fmt.Errorf("host field must be set")
}
// Allow matching for any user, database and mode by default
if fields["user"] == "" {
fields["user"] = "*"
}
if fields["database"] == "" {
fields["database"] = "*"
}
if fields["mode"] == "" {
fields["mode"] = "*"
}
hostField, err := newField(fields["host"])
if err != nil {
return nil, fmt.Errorf(`error initializing "host" field: %w`, err)
}
userField, err := newField(fields["user"])
if err != nil {
return nil, fmt.Errorf(`error initializing "user" field: %w`, err)
}
dbField, err := newField(fields["database"])
if err != nil {
return nil, fmt.Errorf(`error initializing "database" field: %w`, err)
}
if !allowedModes[fields["mode"]] {
return nil, fmt.Errorf(`invalid "mode" field value: %q`, fields["mode"])
}
modeField, err := newField(fields["mode"])
if err != nil {
return nil, fmt.Errorf(`error initializing "mode" field: %w`, err)
}
var timeout *time.Duration
if fields["timeout"] != "" {
timeoutSec, err := strconv.Atoi(fields["timeout"])
if err != nil {
return nil, fmt.Errorf(`error initializing "timeout" field: %w`, err)
}
timeoutVal := time.Duration(timeoutSec) * time.Second
timeout = &timeoutVal
}
return &Metadata{
Title: fields["title"],
Description: fields["description"],
Host: hostField,
User: userField,
Database: dbField,
Mode: modeField,
Timeout: timeout,
}, nil
}
func parseFields(input string) (map[string]string, error) {
result := map[string]string{}
seenKeys := map[string]bool{}
allowed := map[string]bool{}
for _, key := range allowedKeys {
allowed[key] = true
}
matches := reMetaPrefix.FindAllStringSubmatch(input, -1)
if len(matches) == 0 {
return nil, nil
}
for _, match := range matches {
content := reMetaContent.FindAllStringSubmatch(match[1], -1)
if len(content) == 0 {
continue
}
for _, field := range content {
key := field[1]
value := field[2]
if !allowed[key] {
return result, fmt.Errorf("unknown key: %q", key)
}
if seenKeys[key] {
return result, fmt.Errorf("duplicate key: %q", key)
}
seenKeys[key] = true
result[key] = value
}
}
return result, nil
}
func sanitizeMetadata(input string) string {
lines := []string{}
for _, line := range strings.Split(input, "\n") {
line = reMetaPrefix.ReplaceAllString(line, "")
if len(line) > 0 {
lines = append(lines, line)
}
}
return strings.Join(lines, "\n")
}
+146
View File
@@ -0,0 +1,146 @@
package queries
import (
"testing"
"github.com/stretchr/testify/assert"
)
func Test_parseFields(t *testing.T) {
examples := []struct {
input string
err error
vals map[string]string
}{
{input: "", err: nil, vals: nil},
{input: "foobar", err: nil, vals: nil},
{input: "-- no pgweb meta", err: nil, vals: nil},
{
input: `--pgweb: foo=bar`,
err: nil,
vals: map[string]string{},
},
{
input: `--pgweb: host="localhost"`,
err: nil,
vals: map[string]string{"host": "localhost"},
},
{
input: `--pgweb: host="*" user="admin" database ="mydb"; mode = "readonly"`,
err: nil,
vals: map[string]string{
"host": "*",
"database": "mydb",
"user": "admin",
"mode": "readonly",
},
},
}
for _, ex := range examples {
t.Run(ex.input, func(t *testing.T) {
fields, err := parseFields(ex.input)
assert.Equal(t, ex.err, err)
assert.Equal(t, ex.vals, fields)
})
}
}
func Test_parseMetadata(t *testing.T) {
examples := []struct {
input string
err string
check func(meta *Metadata) bool
}{
{
input: `--pgweb: `,
err: `host field must be set`,
},
{
input: `--pgweb: hello="world"`,
err: `unknown key: "hello"`,
},
{
input: `--pgweb: host="localhost" user="anyuser" database="anydb" mode="foo"`,
err: `invalid "mode" field value: "foo"`,
},
{
input: "--pgweb2:",
check: func(m *Metadata) bool {
return m == nil
},
},
{
input: `--pgweb: host="localhost"`,
check: func(m *Metadata) bool {
return m.Host.value == "localhost" &&
m.User.value == "*" &&
m.Database.value == "*" &&
m.Mode.value == "*" &&
m.Timeout == nil
},
},
{
input: `--pgweb: host="localhost" user="anyuser" database="anydb" mode="*"`,
check: func(m *Metadata) bool {
return m.Host.value == "localhost" &&
m.Host.re == nil &&
m.User.value == "anyuser" &&
m.Database.value == "anydb" &&
m.Mode.value == "*" &&
m.Timeout == nil
},
},
{
input: `--pgweb: host="localhost" timeout="foo"`,
err: `error initializing "timeout" field: strconv.Atoi: parsing "foo": invalid syntax`,
},
{
input: `-- pgweb: host="local(host|dev)"`,
check: func(m *Metadata) bool {
return m.Host.value == "local(host|dev)" && m.Host.re != nil &&
m.Host.matches("localhost") && m.Host.matches("localdev") &&
!m.Host.matches("localfoo") && !m.Host.matches("superlocaldev")
},
},
}
for _, ex := range examples {
t.Run(ex.input, func(t *testing.T) {
meta, err := parseMetadata(ex.input)
if ex.err != "" {
assert.Contains(t, err.Error(), ex.err)
}
if ex.check != nil {
assert.Equal(t, true, ex.check(meta))
}
})
}
}
func Test_sanitizeMetadata(t *testing.T) {
examples := []struct {
input string
output string
}{
{input: "", output: ""},
{input: "foo", output: "foo"},
{
input: `
-- pgweb: metadata
query1
-- pgweb: more metadata
query2
`,
output: "query1\nquery2",
},
}
for _, ex := range examples {
t.Run(ex.input, func(t *testing.T) {
assert.Equal(t, ex.output, sanitizeMetadata(ex.input))
})
}
}
+23
View File
@@ -0,0 +1,23 @@
package queries
type Query struct {
ID string
Path string
Meta *Metadata
Data string
}
// IsPermitted returns true if a query is allowed to execute for a given db context
func (q Query) IsPermitted(host, user, database, mode string) bool {
// All fields must be provided for matching
if q.Meta == nil || host == "" || user == "" || database == "" || mode == "" {
return false
}
meta := q.Meta
return meta.Host.matches(host) &&
meta.User.matches(user) &&
meta.Database.matches(database) &&
meta.Mode.matches(mode)
}
+77
View File
@@ -0,0 +1,77 @@
package queries
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestQueryIsPermitted(t *testing.T) {
examples := []struct {
name string
query Query
args []string
expected bool
}{
{
name: "no input provided",
query: makeQuery("localhost", "someuser", "somedb", "default"),
args: makeArgs("", "", "", ""),
expected: false,
},
{
name: "match on host",
query: makeQuery("localhost", "*", "*", "*"),
args: makeArgs("localhost", "user", "db", "default"),
expected: true,
},
{
name: "match on full set",
query: makeQuery("localhost", "user", "database", "mode"),
args: makeArgs("localhost", "someuser", "somedb", "default"),
expected: false,
},
{
name: "match on partial database",
query: makeQuery("localhost", "*", "myapp_*", "*"),
args: makeArgs("localhost", "user", "myapp_development", "default"),
expected: true,
},
{
name: "match on full set but not mode",
query: makeQuery("localhost", "*", "*", "readonly"),
args: makeArgs("localhost", "user", "db", "default"),
expected: false,
},
}
for _, ex := range examples {
t.Run(ex.name, func(t *testing.T) {
result := ex.query.IsPermitted(ex.args[0], ex.args[1], ex.args[2], ex.args[3])
assert.Equal(t, ex.expected, result)
})
}
}
func makeArgs(vals ...string) []string {
return vals
}
func makeQuery(host, user, database, mode string) Query {
mustfield := func(input string) field {
f, err := newField(input)
if err != nil {
panic(err)
}
return f
}
return Query{
Meta: &Metadata{
Host: mustfield(host),
User: mustfield(user),
Database: mustfield(database),
Mode: mustfield(mode),
},
}
}
+88
View File
@@ -0,0 +1,88 @@
package queries
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
var (
ErrQueryDirNotExist = errors.New("queries directory does not exist")
ErrQueryFileNotExist = errors.New("query file does not exist")
)
type Store struct {
dir string
}
func NewStore(dir string) *Store {
return &Store{
dir: dir,
}
}
func (s Store) Read(id string) (*Query, error) {
path := filepath.Join(s.dir, fmt.Sprintf("%s.sql", id))
return readQuery(path)
}
func (s Store) ReadAll() ([]Query, error) {
entries, err := os.ReadDir(s.dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
err = ErrQueryDirNotExist
}
return nil, err
}
queries := []Query{}
for _, entry := range entries {
name := entry.Name()
if filepath.Ext(name) != ".sql" {
continue
}
path := filepath.Join(s.dir, name)
query, err := readQuery(path)
if err != nil {
fmt.Fprintf(os.Stderr, "[WARN] skipping %q query file due to error: %v\n", name, err)
continue
}
if query == nil {
continue
}
queries = append(queries, *query)
}
return queries, nil
}
func readQuery(path string) (*Query, error) {
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, ErrQueryFileNotExist
}
return nil, err
}
dataStr := string(data)
meta, err := parseMetadata(dataStr)
if err != nil {
return nil, err
}
if meta == nil {
return nil, nil
}
return &Query{
ID: strings.Replace(filepath.Base(path), ".sql", "", 1),
Path: path,
Meta: meta,
Data: sanitizeMetadata(dataStr),
}, nil
}
+71
View File
@@ -0,0 +1,71 @@
//go:build !windows
package queries
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestStoreReadAll(t *testing.T) {
t.Run("valid dir", func(t *testing.T) {
queries, err := NewStore("../../data").ReadAll()
assert.NoError(t, err)
assert.Equal(t, 2, len(queries))
})
t.Run("invalid dir", func(t *testing.T) {
queries, err := NewStore("../../data2").ReadAll()
assert.Equal(t, err.Error(), "queries directory does not exist")
assert.Equal(t, 0, len(queries))
})
}
func TestStoreRead(t *testing.T) {
examples := []struct {
id string
err string
check func(*testing.T, *Query)
}{
{id: "foo", err: "query file does not exist"},
{id: "lc_no_meta"},
{id: "lc_invalid_meta", err: `invalid "mode" field value: "foo"`},
{
id: "lc_example1",
check: func(t *testing.T, q *Query) {
assert.Equal(t, "lc_example1", q.ID)
assert.Equal(t, "../../data/lc_example1.sql", q.Path)
assert.Equal(t, "select 'foo'", q.Data)
assert.Equal(t, "localhost", q.Meta.Host.String())
assert.Equal(t, "*", q.Meta.User.String())
assert.Equal(t, "*", q.Meta.Database.String())
},
},
{
id: "lc_example2",
check: func(t *testing.T, q *Query) {
assert.Equal(t, "lc_example2", q.ID)
assert.Equal(t, "../../data/lc_example2.sql", q.Path)
assert.Equal(t, "-- some comment\nselect 'foo'", q.Data)
assert.Equal(t, "localhost", q.Meta.Host.String())
assert.Equal(t, "foo", q.Meta.User.String())
assert.Equal(t, "*", q.Meta.Database.String())
},
},
}
store := NewStore("../../data")
for _, ex := range examples {
t.Run(ex.id, func(t *testing.T) {
query, err := store.Read(ex.id)
if ex.err != "" || err != nil {
assert.Equal(t, ex.err, err.Error())
}
if ex.check != nil {
ex.check(t, query)
}
})
}
}
+9
View File
@@ -14,6 +14,9 @@ var (
//go:embed sql/info.sql
Info string
//go:embed sql/info_simple.sql
InfoSimple string
//go:embed sql/estimated_row_count.sql
EstimatedTableRowCount string
@@ -38,9 +41,15 @@ var (
//go:embed sql/objects.sql
Objects string
//go:embed sql/tables_stats.sql
TablesStats string
//go:embed sql/function.sql
Function string
//go:embed sql/settings.sql
Settings string
// Activity queries for specific PG versions
Activity = map[string]string{
"default": "SELECT * FROM pg_stat_activity WHERE datname = current_database()",
+6
View File
@@ -0,0 +1,6 @@
SELECT
session_user,
current_user,
current_database(),
current_schemas(false),
version()
+1 -1
View File
@@ -42,4 +42,4 @@ WITH all_objects AS (
AND n.nspname NOT IN ('information_schema', 'pg_catalog')
)
SELECT * FROM all_objects
ORDER BY 1, 2
ORDER BY 2, 3
+1
View File
@@ -0,0 +1 @@
SELECT * FROM pg_settings
+1
View File
@@ -10,5 +10,6 @@ JOIN
WHERE
n.nspname = $1
AND relname = $2
AND contype != 'n'
ORDER BY
contype DESC
+1 -1
View File
@@ -1,6 +1,6 @@
SELECT
indexname AS index_name,
pg_size_pretty(pg_table_size((schemaname || '.' || indexname)::regclass)) AS index_size,
pg_size_pretty(pg_table_size(('"' || schemaname || '"."' || indexname || '"')::regclass)) AS index_size,
indexdef AS index_definition
FROM
pg_indexes
+44
View File
@@ -0,0 +1,44 @@
WITH columns_counts AS (
SELECT table_schema, table_name, COUNT(1) AS num
FROM information_schema.columns
GROUP BY table_schema, table_name
),
indexes_counts AS (
SELECT schemaname, tablename, COUNT(1) AS num
FROM pg_indexes
GROUP BY schemaname, tablename
)
SELECT
tables.schemaname AS schema_name,
tables.relname AS table_name,
pg_size_pretty(pg_total_relation_size(tables.relid)) AS total_size,
pg_size_pretty(pg_table_size(tables.relid)) AS data_size,
pg_size_pretty(pg_indexes_size(tables.relid)) AS index_size,
pg_class.reltuples AS estimated_rows_count,
CASE
WHEN pg_class.reltuples >= 0 AND pg_class.reltuples < 1000
THEN pg_class.reltuples::text
WHEN pg_class.reltuples >= 1000 AND pg_class.reltuples < 1000000
THEN ROUND((pg_class.reltuples / 1000))::text || 'K'
WHEN pg_class.reltuples >= 1000000
THEN ROUND(pg_class.reltuples / 1000000)::text || 'M'
END AS estimated_rows,
CASE
WHEN pg_class.reltuples > 1000
THEN ROUND(pg_indexes_size(tables.relid)::numeric / pg_table_size(tables.relid), 2)
END AS index_to_data_ratio,
indexes_counts.num AS indexes_count,
columns_counts.num AS columns_count
FROM
pg_catalog.pg_statio_user_tables AS tables
LEFT JOIN pg_class
ON pg_class.oid = tables.relid
LEFT JOIN indexes_counts
ON indexes_counts.schemaname = tables.schemaname
AND indexes_counts.tablename = tables.relname
LEFT JOIN columns_counts
ON columns_counts.table_schema = tables.schemaname
AND columns_counts.table_name = tables.relname
ORDER BY
pg_total_relation_size(tables.relid) DESC,
pg_table_size(tables.relid) DESC
+67
View File
@@ -357,6 +357,7 @@
#input .actions #query_progress {
display: none;
float: left;
font-size: 12px;
line-height: 30px;
height: 30px;
color: #aaa;
@@ -625,6 +626,72 @@
float: left;
}
#content_modal {
display: none;
width: 60%;
height: 400px;
position: fixed;
top: 20%;
left: 20%;
background: #fff;
border: 1px solid #ccc;
box-shadow: #ddd 0 0 20px;
}
#content_modal .content {
border: 0px none;
position: relative;
padding: 8px;
white-space: break-spaces;
background: #fff;
overflow-y: scroll;
height: 366px;
box-sizing: border-box;
}
#content_modal .title {
font-weight: bold;
line-height: 24px;
background: #f5f5f5;
padding: 4px 4px 4px 8px;
}
#content_modal .actions {
float: right;
}
#content_modal .actions .fa {
float: right;
width: 24px;
height: 24px;
line-height: 24px;
font-size: 13px;
text-align: center;
background: #fff;
border: 1px solid #ddd;
border-radius: 3px;
cursor: pointer;
margin-left: 4px;
}
#content_modal .actions .fa:hover {
border-color: #999;
box-shadow: #eee 0 0 5px;
}
#error_banner {
line-height: 30px;
text-align: center;
background-color: #be2740;
color: #fff;
display: none;
position: fixed;
bottom: 0px;
left: 0px;
right: 0px;
height: 30px;
}
/* -------------------------------------------------------------------------- */
#custom_query {
+35 -3
View File
@@ -87,6 +87,13 @@
<li><a href="#" id="analyze">Analyze Query</a></li>
</ul>
</div>
<div id="load-query-dropdown" class="btn-group left" style="display: none">
<button id="load-local-query" type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown" disabled="disabled">
Template <span class="caret"></span>
</button>
<ul class="dropdown-menu" role="menu">
</ul>
</div>
<div id="query_progress">Please wait, query is executing...</div>
<div class="pull-right">
<span id="result-rows-count"></span>
@@ -139,6 +146,17 @@
</div>
</div>
<div id="content_modal">
<div class="title">
Cell Content
<div class="actions">
<i class="fa fa-times content-modal-action" data-action="close"></i>
<i class="fa fa-copy content-modal-action" data-action="copy"></i>
</div>
</div>
<pre class="content"></pre>
</div>
<div id="connection_window">
<div class="connection-settings">
<div class="header">
@@ -171,23 +189,29 @@
</div>
</div>
<div class="connection-standard-group">
<div class="connection-bookmarks-group">
<div class="form-group bookmarks">
<label class="col-sm-3 control-label">Bookmark</label>
<div class="col-sm-9">
<select class="form-control" id="connection_bookmarks"></select>
</div>
</div>
</div>
<div class="connection-standard-group">
<div class="form-group">
<label class="col-sm-3 control-label">Host</label>
<div class="col-sm-7">
<div class="col-sm-9">
<div class="row">
<div class="col-sm-9 col-xs-9">
<input type="text" id="pg_host" class="form-control" />
</div>
<div class="col-sm-2 no-left-padding">
<div class="col-sm-3 col-xs-3 no-left-padding">
<input type="text" id="pg_port" class="form-control" placeholder="5432" />
</div>
</div>
</div>
</div>
<div class="form-group">
<label class="col-sm-3 control-label">Username</label>
@@ -274,6 +298,7 @@
<div id="tables_context_menu">
<ul class="dropdown-menu" role="menu">
<li><a href="#" data-action="copy">Copy Table Name</a></li>
<li><a href="#" data-action="analyze">Analyze Table</a></li>
<li class="divider"></li>
<li><a href="#" data-action="export" data-format="json">Export to JSON</a></li>
<li><a href="#" data-action="export" data-format="csv">Export to CSV</a></li>
@@ -299,6 +324,11 @@
</div>
<div id="current_database_context_menu">
<ul class="dropdown-menu" role="menu">
<li><a href="#" data-action="show_db_stats">Show Database Stats</a></li>
<li><a href="#" data-action="download_db_stats">Download Database Stats</a></li>
<li class="divider"></li>
<li><a href="#" data-action="server_settings">Show Server Settings</a></li>
<li class="divider"></li>
<li><a href="#" data-action="export">Export SQL dump</a></li>
</ul>
</div>
@@ -312,9 +342,11 @@
</div>
<div id="results_row_menu">
<ul class="dropdown-menu" role="menu">
<li><a href="#" data-action="display_value">Display Value</a></li>
<li><a href="#" data-action="copy_value">Copy Value</a></li>
<li><a href="#" data-action="filter_by_value">Filter Rows By Value</a></li>
</ul>
</div>
<div id="error_banner"></div>
</body>
</html>
+171 -8
View File
@@ -75,17 +75,31 @@ function apiCall(method, path, params, cb) {
},
success: cb,
error: function(xhr, status, data) {
if (status == "timeout") {
switch(status) {
case "error":
if (xhr.readyState == 0) { // 0 = UNSENT
showErrorBanner("Sorry, something went wrong with your request. Refresh the page and try again!");
}
break;
case "timeout":
return cb({ error: "Query timeout after " + timeout + "s" });
}
cb(jQuery.parseJSON(xhr.responseText));
var responseText;
try {
responseText = jQuery.parseJSON(xhr.responseText);
}
catch {
responseText = { error: "Failed to parse the JSON response." };
}
cb(responseText);
}
});
}
function getInfo(cb) { apiCall("get", "/info", {}, cb); }
function getConnection(cb) { apiCall("get", "/connection", {}, cb); }
function getServerSettings(cb) { apiCall("get", "/server_settings", {}, cb); }
function getSchemas(cb) { apiCall("get", "/schemas", {}, cb); }
function getObjects(cb) { apiCall("get", "/objects", {}, cb); }
function getTables(cb) { apiCall("get", "/tables", {}, cb); }
@@ -93,6 +107,7 @@ function getTableRows(table, opts, cb) { apiCall("get", "/tables/" + table
function getTableStructure(table, opts, cb) { apiCall("get", "/tables/" + table, opts, cb); }
function getTableIndexes(table, cb) { apiCall("get", "/tables/" + table + "/indexes", {}, cb); }
function getTableConstraints(table, cb) { apiCall("get", "/tables/" + table + "/constraints", {}, cb); }
function getTablesStats(cb) { apiCall("get", "/tables_stats", {}, cb); }
function getFunction(id, cb) { apiCall("get", "/functions/" + id, {}, cb); }
function getHistory(cb) { apiCall("get", "/history", {}, cb); }
function getBookmarks(cb) { apiCall("get", "/bookmarks", {}, cb); }
@@ -105,6 +120,18 @@ function encodeQuery(query) {
return Base64.encode(query).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ".");
}
function showErrorBanner(text) {
if (window.errBannerTimeout != null) {
clearTimeout(window.errBannerTimeout);
}
window.errBannerTimeout = setTimeout(function() {
$("#error_banner").fadeOut("fast").text("");
}, 3000);
$("#error_banner").text(text).show();
}
function buildSchemaSection(name, objects) {
var section = "";
@@ -159,6 +186,33 @@ function buildSchemaSection(name, objects) {
return section;
}
function loadLocalQueries() {
if (!appFeatures.local_queries) return;
$("body").on("click", "a.load-local-query", function(e) {
var id = $(this).data("id");
apiCall("get", "/local_queries/" + id, {}, function(resp) {
editor.setValue(resp.query);
editor.clearSelection();
});
});
apiCall("get", "/local_queries", {}, function(resp) {
if (resp.error) return;
var container = $("#load-query-dropdown").find(".dropdown-menu");
resp.forEach(function(item) {
var title = item.title || item.id;
$("<li><a href='#' class='load-local-query' data-id='" + item.id + "'>" + title + "</a></li>").appendTo(container);
});
if (resp.length > 0) $("#load-local-query").prop("disabled", "");
$("#load-query-dropdown").show();
});
}
function loadSchemas() {
$("#objects").html("");
@@ -287,6 +341,12 @@ function performTableAction(table, action, el) {
case "copy":
copyToClipboard(table.split('.')[1]);
break;
case "analyze":
executeQuery("ANALYZE " + table, function(data) {
if (data.error) alert(data.error);
resetTable();
});
break;
}
}
@@ -371,7 +431,11 @@ function buildTable(results, sortColumn, sortOrder, options) {
if (results.rows.length == 0) {
$("#results_header").html("");
$("#results_body").html("<tr><td>No records found</td></tr>");
if (results.stats) {
$("#result-rows-count").html(results.stats.query_duration_ms + " ms");
} else {
$("#result-rows-count").html("");
}
$("#results").addClass("empty");
return;
}
@@ -381,9 +445,8 @@ function buildTable(results, sortColumn, sortOrder, options) {
results.columns.forEach(function(col) {
if (col === sortColumn) {
cols += "<th class='table-header-col active' data-name='" + col + "'" + "data-order=" + sortOrder + ">" + col + "&nbsp;" + sortArrow(sortOrder) + "</th>";
}
else {
cols += "<th class='table-header-col active' data-name='" + col + "' data-order=" + sortOrder + ">" + col + "&nbsp;" + sortArrow(sortOrder) + "</th>";
} else {
cols += "<th class='table-header-col' data-name='" + col + "'>" + col + "</th>";
}
});
@@ -416,7 +479,11 @@ function buildTable(results, sortColumn, sortOrder, options) {
$("#results_body").html(rows);
// Show number of rows rendered on the page
if (results.stats) {
$("#result-rows-count").html(results.stats.rows_count + " rows in " + results.stats.query_duration_ms + " ms");
} else {
$("#result-rows-count").html(results.rows.length + " rows");
}
}
function setCurrentTab(id) {
@@ -597,6 +664,32 @@ function showPaginatedTableContent() {
showTableContent(sortColumn, sortOrder);
}
function showDatabaseStats() {
getTablesStats(function(data) {
buildTable(data);
setCurrentTab("table_structure");
$("#input").hide();
$("#body").prop("class", "full");
$("#results").addClass("no-crop");
});
}
function downloadDatabaseStats() {
openInNewWindow("api/tables_stats", { format: "csv", export: "true" });
}
function showServerSettings() {
getServerSettings(function(data) {
buildTable(data);
setCurrentTab("table_content");
$("#input").hide();
$("#body").prop("class", "full");
$("#results").addClass("no-crop");
});
}
function showTableStructure() {
var name = getCurrentObject().name;
@@ -704,13 +797,13 @@ function showActivityPanel() {
}
function showQueryProgressMessage() {
$("#run, #explain-dropdown-toggle, #csv, #json, #xml").prop("disabled", true);
$("#run, #explain-dropdown-toggle, #csv, #json, #xml, #load-local-query").prop("disabled", true);
$("#explain-dropdown").removeClass("open");
$("#query_progress").show();
}
function hideQueryProgressMessage() {
$("#run, #explain-dropdown-toggle, #csv, #json, #xml").prop("disabled", false);
$("#run, #explain-dropdown-toggle, #csv, #json, #xml, #load-local-query").prop("disabled", false);
$("#query_progress").hide();
}
@@ -1023,6 +1116,7 @@ function showConnectionSettings() {
// Show the current postgres version
$(".connection-settings .version").text("v" + appInfo.version).show();
$("#connection_window").show();
initConnectionWindow();
// Check github release page for updates
getLatestReleaseInfo(appInfo);
@@ -1051,11 +1145,32 @@ function showConnectionSettings() {
$(".bookmarks").show();
}
else {
if (appFeatures.bookmarks_only) {
$("#connection_error").html("Running in <b>bookmarks-only</b> mode but <b>NO</b> bookmarks configured.").show();
$(".open-connection").hide();
} else {
$(".bookmarks").hide();
}
}
});
}
function initConnectionWindow() {
if (appFeatures.bookmarks_only) {
$(".connection-group-switch").hide();
$(".connection-scheme-group").hide();
$(".connection-bookmarks-group").show();
$(".connection-standard-group").hide();
$(".connection-ssh-group").hide();
} else {
$(".connection-group-switch").show();
$(".connection-scheme-group").hide();
$(".connection-bookmarks-group").show();
$(".connection-standard-group").show();
$(".connection-ssh-group").hide();
}
}
function getConnectionString() {
var url = $.trim($("#connection_url").val());
var mode = $(".connection-group-switch button.active").attr("data");
@@ -1142,6 +1257,11 @@ function bindTableHeaderMenu() {
var menuItem = $(e.target);
switch(menuItem.data("action")) {
case "display_value":
var value = $(context).text();
$("#content_modal .content").text(value);
$("#content_modal").show();
break;
case "copy_value":
copyToClipboard($(context).text());
break;
@@ -1166,6 +1286,15 @@ function bindCurrentDatabaseMenu() {
var menuItem = $(e.target);
switch(menuItem.data("action")) {
case "show_db_stats":
showDatabaseStats();
break;
case "download_db_stats":
downloadDatabaseStats();
break;
case "server_settings":
showServerSettings();
break;
case "export":
openInNewWindow("api/export");
break;
@@ -1371,8 +1500,39 @@ function onInputResize(event) {
resizeInput(computedHeight);
}
function bindContentModalEvents() {
var contentModal = document.getElementById("content_modal");
$(window).on("click", function(e) {
// Automatically hide the modal on any click outside of the modal window
if (e.target && !contentModal.contains(e.target)) {
$("#content_modal").hide();
}
});
$("#content_modal .content-modal-action").on("click", function() {
switch ($(this).data("action")) {
case "copy":
copyToClipboard($("#content_modal pre").text());
break;
case "close":
$("#content_modal").hide();
break;
}
});
$("#results").on("dblclick", "td > div", function() {
var value = unescapeHtml($(this).html());
if (!value) return;
$("#content_modal pre").html(value);
$("#content_modal").show();
})
}
$(document).ready(function() {
bindInputResizeEvents();
bindContentModalEvents();
$("#table_content").on("click", function() { showTableContent(); });
$("#table_structure").on("click", function() { showTableStructure(); });
@@ -1662,8 +1822,9 @@ $(document).ready(function() {
var button = $(this).find("button.open-connection");
var params = {};
var bookmarkID = $.trim($("#connection_bookmarks").val());
if ($("#connection_bookmarks").val() != "") {
if (bookmarkID != "") {
params["bookmark_id"] = $("#connection_bookmarks").val();
}
else {
@@ -1696,6 +1857,7 @@ $(document).ready(function() {
else {
connected = true;
loadSchemas();
loadLocalQueries();
$("#connection_window").hide();
$("#current_database").text(resp.current_database);
@@ -1736,6 +1898,7 @@ $(document).ready(function() {
connected = true;
loadSchemas();
loadLocalQueries();
$("#current_database").text(resp.current_database);
$("#main").show();