Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3312c357f3 | ||
|
|
fb6091bb83 | ||
|
|
16f88a1336 | ||
|
|
e0fcd058df | ||
|
|
9e37bd27f1 | ||
|
|
6b0b0244d1 | ||
|
|
c6ece21133 | ||
|
|
4919407228 | ||
|
|
1c3c82de18 | ||
|
|
06d73805a8 | ||
|
|
74e8482f96 | ||
|
|
79d8afc715 | ||
|
|
9a5df8487a | ||
|
|
3fb87562ed | ||
|
|
6a52fea44a | ||
|
|
e3085a44ba | ||
|
|
4ce11c039e | ||
|
|
70f62feec8 | ||
|
|
266a516076 | ||
|
|
2e8800150e | ||
|
|
8413681db0 | ||
|
|
ed8a581ff2 | ||
|
|
302aa1d85b | ||
|
|
f5f78eb32e | ||
|
|
e6df3d19fa | ||
|
|
08b1ea71e7 | ||
|
|
7fe302ed7c | ||
|
|
bcd170ab74 | ||
|
|
e63945ee71 | ||
|
|
487e2820f4 | ||
|
|
a716093236 | ||
|
|
f8bfe2de56 | ||
|
|
136b2a6942 | ||
|
|
c2c69e032c | ||
|
|
3ff70b916f | ||
|
|
22eb352180 | ||
|
|
3a32f531a0 | ||
|
|
63f1150056 | ||
|
|
40f582d1ea | ||
|
|
cf86131808 | ||
|
|
f3d6d9d73e | ||
|
|
1b03d78800 | ||
|
|
6489f6a800 | ||
|
|
36436f3aa7 | ||
|
|
db05989d07 | ||
|
|
3b2bec68ef | ||
|
|
9717d3a132 | ||
|
|
f4e7643e22 | ||
|
|
605c483d5b | ||
|
|
8a8b9f07fb | ||
|
|
7ee3c61e38 | ||
|
|
408e23adb3 | ||
|
|
e560f07de6 | ||
|
|
fe5039d17a | ||
|
|
f810c0227b | ||
|
|
10c90bcd01 | ||
|
|
f3a6ce4636 | ||
|
|
87030c8a17 | ||
|
|
614700e69a | ||
|
|
5371b4af56 | ||
|
|
22daaad5a2 | ||
|
|
14d8d80b86 | ||
|
|
e0c2099e91 | ||
|
|
a4185415e2 | ||
|
|
189ca505bc | ||
|
|
0309d88298 | ||
|
|
60532e852b | ||
|
|
d4cfb059ce | ||
|
|
52f7988ebd | ||
|
|
b2067fbc8d | ||
|
|
f037a4b0d2 | ||
|
|
4d32e3b2c5 | ||
|
|
f94bc9a4dd | ||
|
|
e905e5de53 | ||
|
|
08bbb1537e | ||
|
|
941c0acea3 | ||
|
|
bed7ab9564 | ||
|
|
d5e72f92b5 | ||
|
|
6b3c177909 | ||
|
|
c486885159 | ||
|
|
fc0be1dcd1 | ||
|
|
f12cf3725a | ||
|
|
23fb84db76 | ||
|
|
505ee42867 | ||
|
|
41bf189e6b | ||
|
|
1c3ab1fd1c | ||
|
|
5b2d4e4454 | ||
|
|
06212b4c34 | ||
|
|
79bd9c2f4e | ||
|
|
d7ecb5494d | ||
|
|
06be755d56 | ||
|
|
b2b0094b5e | ||
|
|
96bd15b3e2 | ||
|
|
f3353d9007 | ||
|
|
a864cbcee8 | ||
|
|
da671e67cc | ||
|
|
5afecfe52c | ||
|
|
fed842a44d | ||
|
|
16a897e65d | ||
|
|
e4530caab6 | ||
|
|
6bd8d0f9a7 | ||
|
|
239037b4c6 | ||
|
|
12a4bf70c0 | ||
|
|
1897bef08d | ||
|
|
aeb9dc24ad | ||
|
|
bcba666507 | ||
|
|
b31e7f1ea7 | ||
|
|
837e25be74 | ||
|
|
4e3e255575 | ||
|
|
362ecb0bc7 | ||
|
|
0cd61093b9 | ||
|
|
69233cd769 | ||
|
|
0b9e7cdb4e | ||
|
|
afe431c94d | ||
|
|
e0a748812d | ||
|
|
40474d3990 | ||
|
|
41d970e4a7 | ||
|
|
044367b979 | ||
|
|
515f3123dc |
@@ -1,12 +1,21 @@
|
||||
name: checks
|
||||
|
||||
on:
|
||||
- push
|
||||
- pull_request
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
|
||||
|
||||
env:
|
||||
GO_VERSION: 1.19
|
||||
CGO_ENABLED: 0
|
||||
GO_VERSION: "1.25"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -16,10 +25,10 @@ jobs:
|
||||
tests:
|
||||
name: tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
timeout-minutes: 40
|
||||
strategy:
|
||||
matrix:
|
||||
pg_version: [9.6, 10, 11, 12, 13, 14, 15]
|
||||
pg_version: [9.6, 10, 11, 12, 13, 14, 15, 16, 17, 18]
|
||||
|
||||
services:
|
||||
postgres:
|
||||
@@ -41,7 +50,7 @@ jobs:
|
||||
sudo rm -f /etc/apt/sources.list.d/pgdg.list
|
||||
curl --silent https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add
|
||||
echo "deb http://apt.postgresql.org/pub/repos/apt/ `lsb_release -cs`-pgdg main" | sudo tee /etc/apt/sources.list.d/pgdg.list
|
||||
sudo apt-get update && sudo apt-get install -y postgresql-client-15
|
||||
sudo apt-get update && sudo apt-get install -y postgresql-client-18
|
||||
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
@@ -53,7 +62,6 @@ jobs:
|
||||
- run: make test
|
||||
env:
|
||||
MallocNanoZone: 0 # https://github.com/golang/go/issues/49138
|
||||
CGO_ENABLED: 1
|
||||
PGHOST: localhost
|
||||
PGUSER: postgres
|
||||
PGPASSWORD: postgres
|
||||
@@ -72,8 +80,6 @@ jobs:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- run: go mod download
|
||||
- run: make test
|
||||
env:
|
||||
CGO_ENABLED: 1
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -89,9 +95,9 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v3
|
||||
uses: golangci/golangci-lint-action@v8
|
||||
with:
|
||||
version: v1.50.1
|
||||
version: v2.7.2
|
||||
|
||||
fmt:
|
||||
name: fmt
|
||||
|
||||
@@ -3,7 +3,7 @@ name: demo deploy
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
|
||||
env:
|
||||
FLY_API_TOKEN: ${{ secrets.FLY_TOKEN }}
|
||||
|
||||
@@ -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 }}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
.DS_Store
|
||||
.idea
|
||||
.env
|
||||
.envrc
|
||||
pgweb
|
||||
bin
|
||||
tmp/
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
version: "2"
|
||||
linters:
|
||||
disable:
|
||||
- errcheck
|
||||
settings:
|
||||
staticcheck:
|
||||
checks: ["all", "-ST1000", "-ST1003", "-ST1016", "-ST1020", "-ST1021", "-ST1022", "-ST1005", "-QF1004"]
|
||||
+121
-4
@@ -1,9 +1,126 @@
|
||||
# 0.11.12 - 2022-07-05
|
||||
## Changelog
|
||||
|
||||
Current [release](https://github.com/sosedoff/pgweb/releases) is `0.17.0`.
|
||||
|
||||
## 0.17.0 - 2025-11-22
|
||||
|
||||
- `NEW` Update Dockerfile to use Golang 1.24 image, GH-821
|
||||
- `NEW` Bump go crypto package to 0.44.x, GH-820, GH-823
|
||||
- `NEW` Add PostgreSQL 18 support to Github Actions, GH-816
|
||||
- `NEW` Connect backend refactor, GH-801
|
||||
- `NEW` Add server settings view, GH-768
|
||||
- `NEW` Add exec time of empty queries, GH-763
|
||||
- `FIX` Minor typos, GH-764, GH-786
|
||||
- `FIX` Shorten git revision printed in the --version output, GH-770
|
||||
|
||||
## 0.16.2 - 2024-11-02
|
||||
|
||||
- `FIX` Build a new Docker image with PostgreSQL 17 support
|
||||
- `FIX` Run CI against PostgreSQL 17, GH-758
|
||||
- `FIX` Rename master to main branch, GH-750
|
||||
|
||||
## 0.16.1 - 2024-09-07
|
||||
|
||||
- `FIX` Remove linux/arm/v5 from docker release action, GH-742
|
||||
|
||||
## 0.16.0 - 2024-06-04
|
||||
|
||||
- `NEW` Allow database stats downloads, GH-738
|
||||
- `NEW` Add analyze table action, GH-737
|
||||
- `NEW` Bump postgres version used in docker compose to 15, GH-729
|
||||
- `NEW` Build on Go 1.22, GH-726
|
||||
- `FIX` SSH tunnel cleanup and parse fixup, GH-731
|
||||
- `FIX` Drop linux/arm/v5 from docker build, GH-728
|
||||
- `FIX` Propagate CGO_ENABLED environment variable to docker build, GH-724
|
||||
|
||||
## 0.15.0 - 2024-03-14
|
||||
|
||||
- `NEW` Add support for a bookmarks-only mode, GH-716
|
||||
- `FIX` Fix missing indexes by quoting schema/table name to ::regclass, GH-711
|
||||
- `FIX` Continue on parseJSON error, GH-708
|
||||
|
||||
## 0.14.3 - 2024-01-28
|
||||
|
||||
- `NEW` Allow retrying a connection on startup, GH-695
|
||||
- `NEW` Allow setting readonly mode in bookmarks, GH-707
|
||||
- `FIX` Add UPDATE to list of restricted keywords in read-only mode, GH-697
|
||||
|
||||
## 0.14.2 - 2023-10-29
|
||||
|
||||
- `NEW` Execute tests using PostgreSQL 16, GH-691
|
||||
- `FIX` Unclosed database sessions and tunnels, GH-688
|
||||
- `FIX` Use pg_table_size for table stats query, GH-685
|
||||
- `FIX` Use `HasSuffix` to correctly determine URL prefix, GH-684
|
||||
|
||||
## 0.14.1 - 2023-06-17
|
||||
|
||||
- `NEW` Add process start time metric, GH-675
|
||||
- `NEW` Configure pgweb user for docker container, GH-674
|
||||
- `NEW` Updated dockerfile, GH-645
|
||||
- `FIX` Fix typo in the healthy metric, GH-657
|
||||
- `NEW` Use entrypoint instead of cmd in in dockerfile, GH-654
|
||||
|
||||
## 0.14.0 - 2023-02-21
|
||||
|
||||
- `FIX` History page query loading fixup, GH-632
|
||||
- `NEW` Display cell content via context menu, GH-634
|
||||
- `NEW` Handle support/permissions errors in info call, GH-635
|
||||
- `NEW` Show error message when API calls fail, GH-636
|
||||
- `NEW` Add bookmark options to load username/password from env vars, GH-638
|
||||
- `NEW` Add context menu to display database tables stats, GH-639
|
||||
- `NEW` Added Local Queries feature, GH-641
|
||||
- `FIX` Ensure that objects are sorted by schema and name, GH-648
|
||||
- `FIX` Fetch local queries on db connect, GH-650
|
||||
|
||||
## 0.13.1 - 2022-12-27
|
||||
|
||||
- Fix connect flow when `~/.pgweb/bookmarks` directory is not available, GH-631
|
||||
|
||||
## 0.13.0 - 2022-12-25
|
||||
|
||||
- Add support for .pgpass file, GH-617
|
||||
- Request logging additions (request id, forwarded user), GH-618
|
||||
- Establish connections using bookmark ID only, GH-619
|
||||
- Display empty schemas on the sidebar, GH-621
|
||||
- Configure timeout and retries when testing connection status, GH-623
|
||||
- Setup basic prom metrics endpoint, GH-624
|
||||
- Add default connect_timeout option to connection string, GH-626
|
||||
- Add duration_ms to log entries, GH-628
|
||||
- Add query execution stats to api endpoint, GH-629
|
||||
|
||||
## 0.12.0 - 2022-12-13
|
||||
|
||||
- Deprecate usage of Gox for binary builds, GH-571
|
||||
- Add netcat install in dockerfile to provide a way to healthcheck, GH-572
|
||||
- Install latest postgres client in docker image, GH-577
|
||||
- Add support for `PGWEB_` prefix environment variables, GH-585
|
||||
- Fix export URL generation, refactor export code, GH-588
|
||||
- Add logrus-based request logger, GH-589
|
||||
- Configure logger for connect backend, GH-591
|
||||
- Set LDFLAGS for make build/release commands, GH-592
|
||||
- Add internal sessions manager, GH-593
|
||||
- Include index size on the index list view, GH-595
|
||||
- Fix flaky backend connection test, GH-596
|
||||
- Add ability to view and copy views/materialized views definitions, GH-594
|
||||
- Enable dev assets mode with PGWEB_ASSETS_DEVMODE env var, GH-597
|
||||
- Make query input box resizable, GH-599
|
||||
- Deprecate Heroku demo deployments and switch to Fly, GH-600
|
||||
- Handle returning values in update/delete queries, GH-601
|
||||
- Fix panic with invalid time marshaling, GH-602
|
||||
- Configure logging level and format, GH-605
|
||||
- Use go embed to load queries from static files, GH-607
|
||||
- Switch go build target to 1.19, GH-603
|
||||
- Add support for user functions, GH-608
|
||||
- Implement global query timeout option, GH-609
|
||||
- Switch windows tests from Appveyor to Github Actions, GH-611
|
||||
- Fix activity endpoint panic when server version is not detected, GH-612
|
||||
|
||||
## 0.11.12 - 2022-07-05
|
||||
|
||||
- Update base docker image (alpine), update deps, GH-558
|
||||
- Refactor docker images building, include ARM, GH-568
|
||||
|
||||
# 0.11.11 - 2022-03-29
|
||||
## 0.11.11 - 2022-03-29
|
||||
|
||||
- Auto-detect the query from the query source based on user selection, GH-547
|
||||
- Added binary codec base58 as well as improving the help for --binary-codec flag, GH-548
|
||||
@@ -268,7 +385,7 @@
|
||||
## 0.6.0 - 2015-05-31
|
||||
|
||||
- Adds ability to execute only selected SQL query in run command view, [GH-85]
|
||||
- Adds ability to delete/truncate table via context meny on sidebar view
|
||||
- Adds ability to delete/truncate table via context many on sidebar view
|
||||
- Adds ability to export table contents to CSV via context menu on sidebar view
|
||||
- Changes sidebar color scheme to a lighter and better looking one
|
||||
|
||||
@@ -282,7 +399,7 @@
|
||||
|
||||
## 0.5.2 - 2015-04-13
|
||||
|
||||
- Adds a new endpoint /activity that retuns active queries
|
||||
- Adds a new endpoint /activity that returns active queries
|
||||
- Adds tab to view active queries
|
||||
- Adds column sorting when browsing table contents
|
||||
- Fixes SQL query view when switching to table structure view
|
||||
|
||||
+1
-1
@@ -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
@@ -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,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,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'})
|
||||
|
||||
|
||||
@@ -1,60 +1,43 @@
|
||||
# pgweb
|
||||
|
||||
Web-based PostgreSQL database browser written in Go.
|
||||
Simple web-based and cross platform PostgreSQL database explorer.
|
||||
|
||||
[](https://github.com/sosedoff/pgweb/releases)
|
||||
[](https://github.com/sosedoff/pgweb/actions?query=branch%3Amaster)
|
||||
[](https://ci.appveyor.com/project/sosedoff/pgweb)
|
||||
[](https://github.com/sosedoff/pgweb/actions?query=branch%3Amain)
|
||||
[](https://goreportcard.com/report/github.com/sosedoff/pgweb)
|
||||
[](https://godoc.org/github.com/sosedoff/pgweb)
|
||||
[](https://hub.docker.com/r/sosedoff/pgweb/)
|
||||
|
||||
## Overview
|
||||
|
||||
Pgweb is a web-based database browser for PostgreSQL, written in Go and works
|
||||
on OSX, Linux and Windows machines. Main idea behind using Go for backend development
|
||||
is to utilize ability of the compiler to produce zero-dependency binaries for
|
||||
multiple platforms. Pgweb was created as an attempt to build very simple and portable
|
||||
application to work with local or remote PostgreSQL databases.
|
||||
Pgweb is a web-based database explorer for PostgreSQL, written in Go, and works
|
||||
on Mac, Linux and Windows machines. Distributed as a simple binary with zero dependencies.
|
||||
Very easy to use and packs just the right amount of features.
|
||||
|
||||
[See application screenshots](SCREENS.md)
|
||||
|
||||
## Features
|
||||
|
||||
- Cross-platform support OSX/Linux/Windows 32/64-bit
|
||||
- Simple installation (distributed as a single binary)
|
||||
- Zero dependencies
|
||||
- Works with PostgreSQL 9.1+
|
||||
- SSH Connections
|
||||
- Multiple database sessions
|
||||
- Simple database browser
|
||||
- Execute and analyze custom SQL queries
|
||||
- Table and query data export to CSV/JSON/XML
|
||||
- Query history
|
||||
- Server bookmarks
|
||||
- Cross-platform: Mac/Linux/Windows (64bit).
|
||||
- Simple installation (distributed as a single binary).
|
||||
- Zero dependencies.
|
||||
- Works with PostgreSQL 9.1+.
|
||||
- Supports native SSH tunnels.
|
||||
- Multiple database sessions.
|
||||
- Execute and analyze custom SQL queries.
|
||||
- Table and query data export to CSV/JSON/XML.
|
||||
- Query history.
|
||||
- Server bookmarks.
|
||||
|
||||
Visit [WIKI](https://github.com/sosedoff/pgweb/wiki) for more details
|
||||
|
||||
## Pgweb Pro
|
||||
|
||||
Pgweb Pro is the next major version of Pgweb and includes features like:
|
||||
|
||||
- Table structure editing
|
||||
- Data editing (update row content)
|
||||
- Charting
|
||||
- History persistence
|
||||
- Multiple tabs
|
||||
|
||||
Please get in touch via: https://goo.gl/forms/euQOGWg5uPdPH70b2
|
||||
Visit [WIKI](https://github.com/sosedoff/pgweb/wiki) for more details.
|
||||
|
||||
## Demo
|
||||
|
||||
Visit https://pgweb-demo.fly.dev/ to see pgweb in action.
|
||||
Visit https://pgweb-demo.fly.dev/ to see Pgweb in action.
|
||||
|
||||
## Installation
|
||||
|
||||
- [Precompiled binaries](https://github.com/sosedoff/pgweb/releases) for supported
|
||||
operating systems are available.
|
||||
- [Precompiled binaries](https://github.com/sosedoff/pgweb/releases) for supported operating systems are available.
|
||||
- [More installation options](https://github.com/sosedoff/pgweb/wiki/Installation)
|
||||
|
||||
## Usage
|
||||
@@ -89,13 +72,9 @@ pgweb --sessions
|
||||
Or set environment variable:
|
||||
|
||||
```
|
||||
SESSIONS=1 pgweb
|
||||
PGWEB_SESSIONS=1 pgweb
|
||||
```
|
||||
|
||||
## Deploy on Heroku
|
||||
|
||||
[](https://heroku.com/deploy?template=https://github.com/sosedoff/pgweb)
|
||||
|
||||
## Testing
|
||||
|
||||
Before running tests, make sure you have PostgreSQL server running on `localhost:5432`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1,2 @@
|
||||
-- pgweb: host="localhost"
|
||||
select 'foo'
|
||||
@@ -0,0 +1,5 @@
|
||||
-- pgweb: host="localhost"
|
||||
-- some comment
|
||||
-- pgweb: user="foo"
|
||||
|
||||
select 'foo'
|
||||
@@ -0,0 +1,2 @@
|
||||
-- pgweb: host="localhost" mode="foo"
|
||||
select 'foo'
|
||||
@@ -0,0 +1 @@
|
||||
select 'foo'
|
||||
@@ -0,0 +1,2 @@
|
||||
localhost:5432:dbname:username:password
|
||||
127.0.0.1:5432:*:*:password2
|
||||
+15
-2
@@ -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
@@ -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:
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
# fly.toml file generated for pgweb-demo on 2022-11-14T11:19:39-06:00
|
||||
|
||||
app = "pgweb-demo"
|
||||
kill_signal = "SIGINT"
|
||||
kill_timeout = 5
|
||||
|
||||
[env]
|
||||
SESSIONS = "1"
|
||||
|
||||
[experimental]
|
||||
allowed_public_ports = []
|
||||
auto_rollback = true
|
||||
|
||||
[processes]
|
||||
web = "pgweb --sessions --bind=0.0.0.0"
|
||||
web = "pgweb --sessions --bind=0.0.0.0 --metrics --idle-timeout=30"
|
||||
|
||||
[[services]]
|
||||
http_checks = []
|
||||
@@ -20,6 +11,7 @@ kill_timeout = 5
|
||||
processes = ["web"]
|
||||
protocol = "tcp"
|
||||
script_checks = []
|
||||
|
||||
[services.concurrency]
|
||||
hard_limit = 25
|
||||
soft_limit = 20
|
||||
@@ -39,3 +31,7 @@ kill_timeout = 5
|
||||
interval = "15s"
|
||||
restart_limit = 0
|
||||
timeout = "2s"
|
||||
|
||||
[metrics]
|
||||
port = 8081
|
||||
path = "/metrics"
|
||||
|
||||
@@ -1,42 +1,64 @@
|
||||
module github.com/sosedoff/pgweb
|
||||
|
||||
go 1.19
|
||||
go 1.25
|
||||
|
||||
// +heroku goVersion go1.19
|
||||
toolchain go1.25.4
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.1.0
|
||||
github.com/ScaleFT/sshkeys v0.0.0-20200327173127-6142f742bca5
|
||||
github.com/gin-gonic/gin v1.7.7
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/jackc/pgpassfile v1.0.0
|
||||
github.com/jessevdk/go-flags v1.5.0
|
||||
github.com/jmoiron/sqlx v1.3.5
|
||||
github.com/lib/pq v1.10.5
|
||||
github.com/mitchellh/go-homedir v1.1.0
|
||||
github.com/mr-tron/base58 v1.2.0
|
||||
github.com/sirupsen/logrus v1.9.0
|
||||
github.com/stretchr/testify v1.7.1
|
||||
github.com/prometheus/client_golang v1.19.1
|
||||
github.com/sirupsen/logrus v1.9.1
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/tuvistavie/securerandom v0.0.0-20140719024926-15512123a948
|
||||
golang.org/x/crypto v0.0.0-20220511200225-c6db032c6c88
|
||||
go.balki.me/anyhttp v0.5.0
|
||||
golang.org/x/crypto v0.46.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.14.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.4.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dchest/bcrypt_pbkdf v0.0.0-20150205184540-83f37f9c154a // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.0 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.0 // indirect
|
||||
github.com/go-playground/validator/v10 v10.9.0 // indirect
|
||||
github.com/golang/protobuf v1.5.2 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.1 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/leodido/go-urn v1.2.1 // indirect
|
||||
github.com/mattn/go-isatty v0.0.14 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/ugorji/go/codec v1.2.6 // indirect
|
||||
golang.org/x/sys v0.2.0 // indirect
|
||||
golang.org/x/text v0.3.7 // indirect
|
||||
google.golang.org/protobuf v1.27.1 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
|
||||
github.com/prometheus/client_model v0.5.0 // indirect
|
||||
github.com/prometheus/common v0.48.0 // indirect
|
||||
github.com/prometheus/procfs v0.12.0 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.58.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.uber.org/mock v0.6.0 // indirect
|
||||
golang.org/x/arch v0.23.0 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -2,60 +2,68 @@ github.com/BurntSushi/toml v1.1.0 h1:ksErzDEI1khOiGPgpwuI7x2ebx/uXQNw7xJpn9Eq1+I
|
||||
github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
|
||||
github.com/ScaleFT/sshkeys v0.0.0-20200327173127-6142f742bca5 h1:VauE2GcJNZFun2Och6tIT2zJZK1v6jxALQDA9BIji/E=
|
||||
github.com/ScaleFT/sshkeys v0.0.0-20200327173127-6142f742bca5/go.mod h1:gxOHeajFfvGQh/fxlC8oOKBe23xnnJTif00IFFbiT+o=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
|
||||
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
|
||||
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
|
||||
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dchest/bcrypt_pbkdf v0.0.0-20150205184540-83f37f9c154a h1:saTgr5tMLFnmy/yg3qDTft4rE5DY2uJ/cCxCe3q0XTU=
|
||||
github.com/dchest/bcrypt_pbkdf v0.0.0-20150205184540-83f37f9c154a/go.mod h1:Bw9BbhOJVNR+t0jCqx2GC6zv0TGBsShs56Y3gfSCvl0=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.7.7 h1:3DoBmSbJbZAWqXJC3SLjAPfutPJJRN1U5pALB7EeTTs=
|
||||
github.com/gin-gonic/gin v1.7.7/go.mod h1:axIBovoeJpVj8S3BwE0uPMTeReE4+AfFtqpqaZ1qq1U=
|
||||
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
|
||||
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
|
||||
github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU=
|
||||
github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
|
||||
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
|
||||
github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho=
|
||||
github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
|
||||
github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
|
||||
github.com/go-playground/validator/v10 v10.9.0 h1:NgTtmN58D0m8+UuxtYmGztBJB7VnPgjj221I1QHci2A=
|
||||
github.com/go-playground/validator/v10 v10.9.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
|
||||
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.1 h1:3rG3+v8pkhRqoQ/88NYNMHYVGYztCOCIZ7UQhu7H+NE=
|
||||
github.com/goccy/go-yaml v1.19.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jessevdk/go-flags v1.5.0 h1:1jKYvbxEjfUl0fmqTCOfonvskHHXMjBySTLW4y9LFvc=
|
||||
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
|
||||
github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=
|
||||
github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
|
||||
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
|
||||
github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
|
||||
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.10.5 h1:J+gdV2cUmX7ZqL2B0lFcW0m+egaHC2V3lpO8nWxyYiQ=
|
||||
github.com/lib/pq v1.10.5/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg=
|
||||
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
@@ -63,78 +71,81 @@ github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrk
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
|
||||
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||
github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
|
||||
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
|
||||
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
|
||||
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
|
||||
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
|
||||
github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE=
|
||||
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
|
||||
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
|
||||
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.58.0 h1:ggY2pvZaVdB9EyojxL1p+5mptkuHyX5MOSv4dgWF4Ug=
|
||||
github.com/quic-go/quic-go v0.58.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/sirupsen/logrus v1.9.1 h1:Ou41VVR3nMWWmTiEUnj0OlsgOSCUFgsPAOl6jRIcVtQ=
|
||||
github.com/sirupsen/logrus v1.9.1/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tuvistavie/securerandom v0.0.0-20140719024926-15512123a948 h1:yL0l/u242MzDP6D0B5vGC+wxm5WRY+alQZy+dJk3bFI=
|
||||
github.com/tuvistavie/securerandom v0.0.0-20140719024926-15512123a948/go.mod h1:a06d/M1pxWi51qiSrfGMHaEydtuXT06nha8N2aNQuXk=
|
||||
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
|
||||
github.com/ugorji/go v1.2.6/go.mod h1:anCg0y61KIhDlPZmnH+so+RQbysYVyDko0IMgJv0Nn0=
|
||||
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
|
||||
github.com/ugorji/go/codec v1.2.6 h1:7kbGefxLoDBuYXOms4yD7223OpNMMPNPZxXk5TvFcyQ=
|
||||
github.com/ugorji/go/codec v1.2.6/go.mod h1:V6TCNZ4PHqoHGFZuSG1W8nrCzzdgA2DozYxWFFpvxTw=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.balki.me/anyhttp v0.5.0 h1:uys0oRciBpZfwtxXAevScKy6amIQBXyDrcV0EtGF5zo=
|
||||
go.balki.me/anyhttp v0.5.0/go.mod h1:JhfekOIjgVODoVqUCficjpIgmB3wwlB7jhN0eN2EZ/s=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
|
||||
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20220511200225-c6db032c6c88 h1:Tgea0cVUD0ivh5ADBX4WwuI12DUd2to3nCYe2eayMIw=
|
||||
golang.org/x/crypto v0.0.0-20220511200225-c6db032c6c88/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200219091948-cb0a6d8edb6c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A=
|
||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
|
||||
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ=
|
||||
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
+198
-28
@@ -15,7 +15,10 @@ import (
|
||||
"github.com/sosedoff/pgweb/pkg/bookmarks"
|
||||
"github.com/sosedoff/pgweb/pkg/client"
|
||||
"github.com/sosedoff/pgweb/pkg/command"
|
||||
"github.com/sosedoff/pgweb/pkg/connect"
|
||||
"github.com/sosedoff/pgweb/pkg/connection"
|
||||
"github.com/sosedoff/pgweb/pkg/metrics"
|
||||
"github.com/sosedoff/pgweb/pkg/queries"
|
||||
"github.com/sosedoff/pgweb/pkg/shared"
|
||||
"github.com/sosedoff/pgweb/static"
|
||||
)
|
||||
@@ -26,6 +29,9 @@ var (
|
||||
|
||||
// DbSessions represents the mapping for client connections
|
||||
DbSessions *SessionManager
|
||||
|
||||
// QueryStore reads the SQL queries stores in the home directory
|
||||
QueryStore *queries.Store
|
||||
)
|
||||
|
||||
// DB returns a database connection from the client context
|
||||
@@ -87,18 +93,18 @@ func GetSessions(c *gin.Context) {
|
||||
|
||||
// ConnectWithBackend creates a new connection based on backend resource
|
||||
func ConnectWithBackend(c *gin.Context) {
|
||||
// Setup a new backend client
|
||||
backend := Backend{
|
||||
Endpoint: command.Opts.ConnectBackend,
|
||||
Token: command.Opts.ConnectToken,
|
||||
PassHeaders: strings.Split(command.Opts.ConnectHeaders, ","),
|
||||
backend := connect.NewBackend(command.Opts.ConnectBackend, command.Opts.ConnectToken)
|
||||
backend.SetLogger(logger)
|
||||
|
||||
if command.Opts.ConnectHeaders != "" {
|
||||
backend.SetPassHeaders(strings.Split(command.Opts.ConnectHeaders, ","))
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
|
||||
defer cancel()
|
||||
|
||||
// Fetch connection credentials
|
||||
cred, err := backend.FetchCredential(ctx, c.Param("resource"), c)
|
||||
cred, err := backend.FetchCredential(ctx, c.Param("resource"), c.Request.Header)
|
||||
if err != nil {
|
||||
badRequest(c, err)
|
||||
return
|
||||
@@ -142,27 +148,18 @@ func Connect(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var sshInfo *shared.SSHInfo
|
||||
url := c.Request.FormValue("url")
|
||||
var (
|
||||
cl *client.Client
|
||||
err error
|
||||
)
|
||||
|
||||
if url == "" {
|
||||
badRequest(c, errURLRequired)
|
||||
return
|
||||
if bookmarkID := c.Request.FormValue("bookmark_id"); bookmarkID != "" {
|
||||
cl, err = ConnectWithBookmark(bookmarkID)
|
||||
} else if command.Opts.BookmarksOnly {
|
||||
err = errNotPermitted
|
||||
} else {
|
||||
cl, err = ConnectWithURL(c)
|
||||
}
|
||||
|
||||
opts := command.Options{URL: url}
|
||||
url, err := connection.FormatURL(opts)
|
||||
|
||||
if err != nil {
|
||||
badRequest(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if c.Request.FormValue("ssh") != "" {
|
||||
sshInfo = parseSshInfo(c)
|
||||
}
|
||||
|
||||
cl, err := client.NewFromUrl(url, sshInfo)
|
||||
if err != nil {
|
||||
badRequest(c, err)
|
||||
return
|
||||
@@ -187,6 +184,39 @@ func Connect(c *gin.Context) {
|
||||
successResponse(c, info.Format()[0])
|
||||
}
|
||||
|
||||
func ConnectWithURL(c *gin.Context) (*client.Client, error) {
|
||||
url := c.Request.FormValue("url")
|
||||
if url == "" {
|
||||
return nil, errURLRequired
|
||||
}
|
||||
|
||||
url, err := connection.FormatURL(command.Options{
|
||||
URL: url,
|
||||
Passfile: command.Opts.Passfile,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var sshInfo *shared.SSHInfo
|
||||
if c.Request.FormValue("ssh") != "" {
|
||||
sshInfo = parseSshInfo(c)
|
||||
}
|
||||
|
||||
return client.NewFromUrl(url, sshInfo)
|
||||
}
|
||||
|
||||
func ConnectWithBookmark(id string) (*client.Client, error) {
|
||||
manager := bookmarks.NewManager(command.Opts.BookmarksDir)
|
||||
|
||||
bookmark, err := manager.Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return client.NewFromBookmark(bookmark)
|
||||
}
|
||||
|
||||
// SwitchDb perform database switch for the client connection
|
||||
func SwitchDb(c *gin.Context) {
|
||||
if command.Opts.LockSession {
|
||||
@@ -256,6 +286,12 @@ func Disconnect(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if command.Opts.Sessions {
|
||||
result := DbSessions.Remove(getSessionId(c.Request))
|
||||
successResponse(c, gin.H{"success": result})
|
||||
return
|
||||
}
|
||||
|
||||
conn := DB(c)
|
||||
if conn == nil {
|
||||
badRequest(c, errNotConnected)
|
||||
@@ -268,6 +304,7 @@ func Disconnect(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
DbClient = nil
|
||||
successResponse(c, gin.H{"success": true})
|
||||
}
|
||||
|
||||
@@ -431,8 +468,14 @@ func GetHistory(c *gin.Context) {
|
||||
|
||||
// GetConnectionInfo renders information about current connection
|
||||
func GetConnectionInfo(c *gin.Context) {
|
||||
res, err := DB(c).Info()
|
||||
conn := DB(c)
|
||||
|
||||
if err := conn.TestWithTimeout(5 * time.Second); err != nil {
|
||||
badRequest(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
res, err := conn.Info()
|
||||
if err != nil {
|
||||
badRequest(c, err)
|
||||
return
|
||||
@@ -444,6 +487,12 @@ func GetConnectionInfo(c *gin.Context) {
|
||||
successResponse(c, info)
|
||||
}
|
||||
|
||||
// GetServerSettings renders a list of all server settings
|
||||
func GetServerSettings(c *gin.Context) {
|
||||
res, err := DB(c).ServerSettings()
|
||||
serveResult(c, res, err)
|
||||
}
|
||||
|
||||
// GetActivity renders a list of running queries
|
||||
func GetActivity(c *gin.Context) {
|
||||
res, err := DB(c).Activity()
|
||||
@@ -462,8 +511,51 @@ func GetTableConstraints(c *gin.Context) {
|
||||
serveResult(c, res, err)
|
||||
}
|
||||
|
||||
// GetTablesStats renders data sizes and estimated rows for all tables in the database
|
||||
func GetTablesStats(c *gin.Context) {
|
||||
db := DB(c)
|
||||
|
||||
connCtx, err := db.GetConnContext()
|
||||
if err != nil {
|
||||
badRequest(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
res, err := db.TablesStats()
|
||||
if err != nil {
|
||||
badRequest(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
format := getQueryParam(c, "format")
|
||||
if format == "" {
|
||||
format = "json"
|
||||
}
|
||||
|
||||
// Save as attachment if exporting parameter is set
|
||||
if getQueryParam(c, "export") == "true" {
|
||||
ts := time.Now().Format(time.DateOnly)
|
||||
|
||||
filename := fmt.Sprintf("pgweb-dbstats-%s-%s.%s", connCtx.Database, ts, format)
|
||||
c.Writer.Header().Set("Content-disposition", "attachment;filename="+filename)
|
||||
}
|
||||
|
||||
switch format {
|
||||
case "json":
|
||||
c.JSON(http.StatusOK, res)
|
||||
case "csv":
|
||||
c.Data(http.StatusOK, "text/csv", res.CSV())
|
||||
case "xml":
|
||||
c.XML(200, res)
|
||||
default:
|
||||
badRequest(c, "invalid format")
|
||||
}
|
||||
}
|
||||
|
||||
// HandleQuery runs the database query
|
||||
func HandleQuery(query string, c *gin.Context) {
|
||||
metrics.IncrementQueriesCount()
|
||||
|
||||
rawQuery, err := base64.StdEncoding.DecodeString(desanitize64(query))
|
||||
if err == nil {
|
||||
query = string(rawQuery)
|
||||
@@ -500,8 +592,9 @@ func HandleQuery(query string, c *gin.Context) {
|
||||
|
||||
// GetBookmarks renders the list of available bookmarks
|
||||
func GetBookmarks(c *gin.Context) {
|
||||
bookmarks, err := bookmarks.ReadAll(bookmarks.Path(command.Opts.BookmarksDir))
|
||||
serveResult(c, bookmarks, err)
|
||||
manager := bookmarks.NewManager(command.Opts.BookmarksDir)
|
||||
ids, err := manager.ListIDs()
|
||||
serveResult(c, ids, err)
|
||||
}
|
||||
|
||||
// GetInfo renders the pgweb system information
|
||||
@@ -511,6 +604,8 @@ func GetInfo(c *gin.Context) {
|
||||
"features": gin.H{
|
||||
"session_lock": command.Opts.LockSession,
|
||||
"query_timeout": command.Opts.QueryTimeout,
|
||||
"local_queries": QueryStore != nil,
|
||||
"bookmarks_only": command.Opts.BookmarksOnly,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -562,3 +657,78 @@ func GetFunction(c *gin.Context) {
|
||||
res, err := DB(c).Function(c.Param("id"))
|
||||
serveResult(c, res, err)
|
||||
}
|
||||
|
||||
func GetLocalQueries(c *gin.Context) {
|
||||
connCtx, err := DB(c).GetConnContext()
|
||||
if err != nil {
|
||||
badRequest(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
storeQueries, err := QueryStore.ReadAll()
|
||||
if err != nil {
|
||||
badRequest(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
queries := []localQuery{}
|
||||
for _, q := range storeQueries {
|
||||
if !q.IsPermitted(connCtx.Host, connCtx.User, connCtx.Database, connCtx.Mode) {
|
||||
continue
|
||||
}
|
||||
|
||||
queries = append(queries, localQuery{
|
||||
ID: q.ID,
|
||||
Title: q.Meta.Title,
|
||||
Description: q.Meta.Description,
|
||||
Query: cleanQuery(q.Data),
|
||||
})
|
||||
}
|
||||
|
||||
successResponse(c, queries)
|
||||
}
|
||||
|
||||
func RunLocalQuery(c *gin.Context) {
|
||||
query, err := QueryStore.Read(c.Param("id"))
|
||||
if err != nil {
|
||||
if err == queries.ErrQueryFileNotExist {
|
||||
query = nil
|
||||
} else {
|
||||
badRequest(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if query == nil {
|
||||
errorResponse(c, 404, "query not found")
|
||||
return
|
||||
}
|
||||
|
||||
connCtx, err := DB(c).GetConnContext()
|
||||
if err != nil {
|
||||
badRequest(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if !query.IsPermitted(connCtx.Host, connCtx.User, connCtx.Database, connCtx.Mode) {
|
||||
errorResponse(c, 404, "query not found")
|
||||
return
|
||||
}
|
||||
|
||||
if c.Request.Method == http.MethodGet {
|
||||
successResponse(c, localQuery{
|
||||
ID: query.ID,
|
||||
Title: query.Meta.Title,
|
||||
Description: query.Meta.Description,
|
||||
Query: query.Data,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
statement := cleanQuery(query.Data)
|
||||
if statement == "" {
|
||||
badRequest(c, errQueryRequired)
|
||||
return
|
||||
}
|
||||
|
||||
HandleQuery(statement, c)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -155,11 +155,12 @@ func assetContentType(name string) string {
|
||||
|
||||
// Send a query result to client
|
||||
func serveResult(c *gin.Context, result interface{}, err interface{}) {
|
||||
if err == nil {
|
||||
successResponse(c, result)
|
||||
} else {
|
||||
if err != nil {
|
||||
badRequest(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
successResponse(c, result)
|
||||
}
|
||||
|
||||
// Send successful response back to client
|
||||
|
||||
+26
-1
@@ -8,6 +8,8 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/sosedoff/pgweb/pkg/command"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -29,6 +31,7 @@ func SetLogger(l *logrus.Logger) {
|
||||
|
||||
func RequestLogger(logger *logrus.Logger) gin.HandlerFunc {
|
||||
debug := logger.Level > logrus.InfoLevel
|
||||
logForwardedUser := command.Opts.LogForwardedUser
|
||||
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
@@ -54,10 +57,24 @@ func RequestLogger(logger *logrus.Logger) gin.HandlerFunc {
|
||||
"status": status,
|
||||
"method": c.Request.Method,
|
||||
"remote_addr": c.ClientIP(),
|
||||
"duration": latency,
|
||||
"duration": latency.String(),
|
||||
"duration_ms": latency.Milliseconds(),
|
||||
"path": path,
|
||||
}
|
||||
|
||||
if reqID := getRequestID(c); reqID != "" {
|
||||
fields["id"] = reqID
|
||||
}
|
||||
|
||||
if logForwardedUser {
|
||||
if forwardedUser := c.GetHeader("X-Forwarded-User"); forwardedUser != "" {
|
||||
fields["forwarded_user"] = forwardedUser
|
||||
}
|
||||
if forwardedEmail := c.GetHeader("X-Forwarded-Email"); forwardedEmail != "" {
|
||||
fields["forwarded_email"] = forwardedEmail
|
||||
}
|
||||
}
|
||||
|
||||
if err := c.Errors.Last(); err != nil {
|
||||
fields["error"] = err.Error()
|
||||
}
|
||||
@@ -88,3 +105,11 @@ func RequestLogger(logger *logrus.Logger) gin.HandlerFunc {
|
||||
func sanitizeLogPath(str string) string {
|
||||
return reConnectToken.ReplaceAllString(str, "/connect/REDACTED")
|
||||
}
|
||||
|
||||
func getRequestID(c *gin.Context) string {
|
||||
id := c.GetHeader("x-request-id")
|
||||
if id == "" {
|
||||
id = c.GetHeader("x-amzn-trace-id")
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_getRequestID(t *testing.T) {
|
||||
examples := []struct {
|
||||
headers map[string]string
|
||||
result string
|
||||
}{
|
||||
{map[string]string{}, ""},
|
||||
{map[string]string{"X-Request-ID": "foo"}, "foo"},
|
||||
{map[string]string{"x-request-id": "foo"}, "foo"},
|
||||
{map[string]string{"x-request-id": "foo"}, "foo"},
|
||||
{map[string]string{"x-request-id": "foo", "x-amzn-trace-id": "amz"}, "foo"},
|
||||
}
|
||||
|
||||
for _, ex := range examples {
|
||||
req := &http.Request{Header: http.Header{}}
|
||||
for k, v := range ex.headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
assert.Equal(t, ex.result, getRequestID(&gin.Context{Request: req}))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/sosedoff/pgweb/pkg/command"
|
||||
"github.com/sosedoff/pgweb/pkg/metrics"
|
||||
)
|
||||
|
||||
func SetupMiddlewares(group *gin.RouterGroup) {
|
||||
@@ -34,6 +35,7 @@ func SetupRoutes(router *gin.Engine) {
|
||||
api.POST("/switchdb", SwitchDb)
|
||||
api.GET("/databases", GetDatabases)
|
||||
api.GET("/connection", GetConnectionInfo)
|
||||
api.GET("/server_settings", GetServerSettings)
|
||||
api.GET("/activity", GetActivity)
|
||||
api.GET("/schemas", GetSchemas)
|
||||
api.GET("/objects", GetObjects)
|
||||
@@ -42,6 +44,7 @@ func SetupRoutes(router *gin.Engine) {
|
||||
api.GET("/tables/:table/info", GetTableInfo)
|
||||
api.GET("/tables/:table/indexes", GetTableIndexes)
|
||||
api.GET("/tables/:table/constraints", GetTableConstraints)
|
||||
api.GET("/tables_stats", GetTablesStats)
|
||||
api.GET("/functions/:id", GetFunction)
|
||||
api.GET("/query", RunQuery)
|
||||
api.POST("/query", RunQuery)
|
||||
@@ -52,4 +55,14 @@ func SetupRoutes(router *gin.Engine) {
|
||||
api.GET("/history", GetHistory)
|
||||
api.GET("/bookmarks", GetBookmarks)
|
||||
api.GET("/export", DataExport)
|
||||
api.GET("/local_queries", requireLocalQueries(), GetLocalQueries)
|
||||
api.GET("/local_queries/:id", requireLocalQueries(), RunLocalQuery)
|
||||
api.POST("/local_queries/:id", requireLocalQueries(), RunLocalQuery)
|
||||
}
|
||||
|
||||
func SetupMetrics(engine *gin.Engine) {
|
||||
if command.Opts.MetricsEnabled && command.Opts.MetricsAddr == "" {
|
||||
// NOTE: We're not supporting the MetricsPath CLI option here to avoid the route conflicts.
|
||||
engine.GET("/metrics", gin.WrapH(metrics.NewHandler()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/sosedoff/pgweb/pkg/client"
|
||||
"github.com/sosedoff/pgweb/pkg/metrics"
|
||||
)
|
||||
|
||||
type SessionManager struct {
|
||||
@@ -42,24 +43,29 @@ func (m *SessionManager) IDs() []string {
|
||||
|
||||
func (m *SessionManager) Sessions() map[string]*client.Client {
|
||||
m.mu.Lock()
|
||||
sessions := m.sessions
|
||||
defer m.mu.Unlock()
|
||||
|
||||
sessions := make(map[string]*client.Client, len(m.sessions))
|
||||
for k, v := range m.sessions {
|
||||
sessions[k] = v
|
||||
}
|
||||
|
||||
return sessions
|
||||
}
|
||||
|
||||
func (m *SessionManager) Get(id string) *client.Client {
|
||||
m.mu.Lock()
|
||||
c := m.sessions[id]
|
||||
m.mu.Unlock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
return c
|
||||
return m.sessions[id]
|
||||
}
|
||||
|
||||
func (m *SessionManager) Add(id string, conn *client.Client) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.sessions[id] = conn
|
||||
m.mu.Unlock()
|
||||
metrics.SetSessionsCount(len(m.sessions))
|
||||
}
|
||||
|
||||
func (m *SessionManager) Remove(id string) bool {
|
||||
@@ -72,15 +78,15 @@ func (m *SessionManager) Remove(id string) bool {
|
||||
delete(m.sessions, id)
|
||||
}
|
||||
|
||||
metrics.SetSessionsCount(len(m.sessions))
|
||||
return ok
|
||||
}
|
||||
|
||||
func (m *SessionManager) Len() int {
|
||||
m.mu.Lock()
|
||||
sz := len(m.sessions)
|
||||
m.mu.Unlock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
return sz
|
||||
return len(m.sessions)
|
||||
}
|
||||
|
||||
func (m *SessionManager) Cleanup() int {
|
||||
@@ -114,7 +120,7 @@ func (m *SessionManager) RunPeriodicCleanup() {
|
||||
}
|
||||
|
||||
func (m *SessionManager) staleSessions() []string {
|
||||
m.mu.TryLock()
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package api
|
||||
|
||||
type localQuery struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Query string `json:"query"`
|
||||
}
|
||||
+25
-113
@@ -1,13 +1,7 @@
|
||||
package bookmarks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
"github.com/mitchellh/go-homedir"
|
||||
|
||||
"github.com/sosedoff/pgweb/pkg/command"
|
||||
"github.com/sosedoff/pgweb/pkg/shared"
|
||||
@@ -15,14 +9,18 @@ import (
|
||||
|
||||
// Bookmark contains information about bookmarked database connection
|
||||
type Bookmark struct {
|
||||
URL string `json:"url"` // Postgres connection URL
|
||||
Host string `json:"host"` // Server hostname
|
||||
Port int `json:"port"` // Server port
|
||||
User string `json:"user"` // Database user
|
||||
Password string `json:"password"` // User password
|
||||
Database string `json:"database"` // Database name
|
||||
SSLMode string `json:"ssl"` // Connection SSL mode
|
||||
SSH *shared.SSHInfo `json:"ssh"` // SSH tunnel config
|
||||
ID string // ID generated from the filename
|
||||
URL string // Postgres connection URL
|
||||
Host string // Server hostname
|
||||
Port int // Server port
|
||||
User string // Database user
|
||||
UserVar string // Database user environment variable
|
||||
Password string // User password
|
||||
PasswordVar string // User password environment variable
|
||||
Database string // Database name
|
||||
SSLMode string // Connection SSL mode
|
||||
SSH *shared.SSHInfo // SSH tunnel config
|
||||
ReadOnly bool // Enable read-only transaction mode
|
||||
}
|
||||
|
||||
// SSHInfoIsEmpty returns true if ssh configuration is not provided
|
||||
@@ -32,110 +30,24 @@ func (b Bookmark) SSHInfoIsEmpty() bool {
|
||||
|
||||
// ConvertToOptions returns an options struct from connection details
|
||||
func (b Bookmark) ConvertToOptions() command.Options {
|
||||
user := b.User
|
||||
if b.User == "" {
|
||||
user = os.Getenv(b.UserVar)
|
||||
}
|
||||
|
||||
pass := b.Password
|
||||
if b.Password == "" {
|
||||
pass = os.Getenv(b.PasswordVar)
|
||||
}
|
||||
|
||||
return command.Options{
|
||||
URL: b.URL,
|
||||
Host: b.Host,
|
||||
Port: b.Port,
|
||||
User: b.User,
|
||||
Pass: b.Password,
|
||||
User: user,
|
||||
Pass: pass,
|
||||
DbName: b.Database,
|
||||
SSLMode: b.SSLMode,
|
||||
ReadOnly: b.ReadOnly,
|
||||
}
|
||||
}
|
||||
|
||||
func readServerConfig(path string) (Bookmark, error) {
|
||||
bookmark := Bookmark{}
|
||||
|
||||
buff, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return bookmark, err
|
||||
}
|
||||
|
||||
_, err = toml.Decode(string(buff), &bookmark)
|
||||
|
||||
if bookmark.Port == 0 {
|
||||
bookmark.Port = 5432
|
||||
}
|
||||
|
||||
// List of all supported postgres modes
|
||||
modes := []string{"disable", "allow", "prefer", "require", "verify-ca", "verify-full"}
|
||||
valid := false
|
||||
|
||||
for _, mode := range modes {
|
||||
if bookmark.SSLMode == mode {
|
||||
valid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to a default mode if mode is not set or invalid
|
||||
// Typical typo: ssl mode set to "disabled"
|
||||
if bookmark.SSLMode == "" || !valid {
|
||||
bookmark.SSLMode = "disable"
|
||||
}
|
||||
|
||||
// Set default SSH port if it's not provided by user
|
||||
if bookmark.SSH != nil && bookmark.SSH.Port == "" {
|
||||
bookmark.SSH.Port = "22"
|
||||
}
|
||||
|
||||
return bookmark, err
|
||||
}
|
||||
|
||||
func fileBasename(path string) string {
|
||||
filename := filepath.Base(path)
|
||||
return strings.Replace(filename, filepath.Ext(path), "", 1)
|
||||
}
|
||||
|
||||
// Path returns bookmarks storage path
|
||||
func Path(overrideDir string) string {
|
||||
if overrideDir == "" {
|
||||
path, _ := homedir.Dir()
|
||||
return fmt.Sprintf("%s/.pgweb/bookmarks", path)
|
||||
}
|
||||
return overrideDir
|
||||
}
|
||||
|
||||
// ReadAll returns all available bookmarks
|
||||
func ReadAll(path string) (map[string]Bookmark, error) {
|
||||
results := map[string]Bookmark{}
|
||||
|
||||
files, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return results, err
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
if filepath.Ext(file.Name()) != ".toml" {
|
||||
continue
|
||||
}
|
||||
|
||||
fullPath := filepath.Join(path, file.Name())
|
||||
key := fileBasename(file.Name())
|
||||
config, err := readServerConfig(fullPath)
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("%s parse error: %s\n", fullPath, err)
|
||||
continue
|
||||
}
|
||||
|
||||
results[key] = config
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetBookmark reads an existing bookmark
|
||||
func GetBookmark(bookmarkPath string, bookmarkName string) (Bookmark, error) {
|
||||
bookmarks, err := ReadAll(bookmarkPath)
|
||||
if err != nil {
|
||||
return Bookmark{}, err
|
||||
}
|
||||
|
||||
bookmark, ok := bookmarks[bookmarkName]
|
||||
if !ok {
|
||||
return Bookmark{}, fmt.Errorf("couldn't find a bookmark with name %s", bookmarkName)
|
||||
}
|
||||
|
||||
return bookmark, nil
|
||||
}
|
||||
|
||||
@@ -8,115 +8,93 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_Invalid_Bookmark_Files(t *testing.T) {
|
||||
_, err := readServerConfig("foobar")
|
||||
assert.Error(t, err)
|
||||
|
||||
_, err = readServerConfig("../../data/invalid.toml")
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "toml: line 1: expected '.' or '=', but got 'e' instead", err.Error())
|
||||
}
|
||||
|
||||
func Test_Bookmark(t *testing.T) {
|
||||
bookmark, err := readServerConfig("../../data/bookmark.toml")
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, "localhost", bookmark.Host)
|
||||
assert.Equal(t, 5432, bookmark.Port)
|
||||
assert.Equal(t, "postgres", bookmark.User)
|
||||
assert.Equal(t, "mydatabase", bookmark.Database)
|
||||
assert.Equal(t, "disable", bookmark.SSLMode)
|
||||
assert.Equal(t, "", bookmark.Password)
|
||||
assert.Equal(t, "", bookmark.URL)
|
||||
|
||||
bookmark, err = readServerConfig("../../data/bookmark_invalid_ssl.toml")
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, "disable", bookmark.SSLMode)
|
||||
}
|
||||
|
||||
func Test_Bookmark_URL(t *testing.T) {
|
||||
bookmark, err := readServerConfig("../../data/bookmark_url.toml")
|
||||
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, "postgres://username:password@host:port/database?sslmode=disable", bookmark.URL)
|
||||
assert.Equal(t, "", bookmark.Host)
|
||||
assert.Equal(t, 5432, bookmark.Port)
|
||||
assert.Equal(t, "", bookmark.User)
|
||||
assert.Equal(t, "", bookmark.Database)
|
||||
assert.Equal(t, "disable", bookmark.SSLMode)
|
||||
assert.Equal(t, "", bookmark.Password)
|
||||
}
|
||||
|
||||
func Test_Bookmarks_Path(t *testing.T) {
|
||||
assert.NotEqual(t, "/.pgweb/bookmarks", Path(""))
|
||||
}
|
||||
|
||||
func Test_Basename(t *testing.T) {
|
||||
assert.Equal(t, "filename", fileBasename("filename.toml"))
|
||||
assert.Equal(t, "filename", fileBasename("path/filename.toml"))
|
||||
assert.Equal(t, "filename", fileBasename("~/long/path/filename.toml"))
|
||||
assert.Equal(t, "filename", fileBasename("filename"))
|
||||
}
|
||||
|
||||
func Test_ReadBookmarks_Invalid(t *testing.T) {
|
||||
bookmarks, err := ReadAll("foobar")
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, 0, len(bookmarks))
|
||||
}
|
||||
|
||||
func Test_ReadBookmarks(t *testing.T) {
|
||||
bookmarks, err := ReadAll("../../data")
|
||||
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, 3, len(bookmarks))
|
||||
}
|
||||
|
||||
func Test_GetBookmark(t *testing.T) {
|
||||
expBookmark := Bookmark{
|
||||
|
||||
Host: "localhost",
|
||||
Port: 5432,
|
||||
User: "postgres",
|
||||
Password: "",
|
||||
Database: "mydatabase",
|
||||
SSLMode: "disable",
|
||||
}
|
||||
b, err := GetBookmark("../../data", "bookmark")
|
||||
if assert.NoError(t, err) {
|
||||
assert.Equal(t, expBookmark, b)
|
||||
}
|
||||
|
||||
_, err = GetBookmark("../../data", "bar")
|
||||
expErrStr := "couldn't find a bookmark with name bar"
|
||||
assert.Equal(t, expErrStr, err.Error())
|
||||
|
||||
_, err = GetBookmark("foo", "bookmark")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func Test_Bookmark_SSHInfoIsEmpty(t *testing.T) {
|
||||
emptySSH := &shared.SSHInfo{
|
||||
func TestBookmarkSSHInfoIsEmpty(t *testing.T) {
|
||||
t.Run("empty", func(t *testing.T) {
|
||||
info := &shared.SSHInfo{
|
||||
Host: "",
|
||||
Port: "",
|
||||
User: "",
|
||||
}
|
||||
populatedSSH := &shared.SSHInfo{
|
||||
Host: "localhost",
|
||||
Port: "8080",
|
||||
User: "postgres",
|
||||
}
|
||||
|
||||
b := Bookmark{SSH: nil}
|
||||
assert.True(t, b.SSHInfoIsEmpty())
|
||||
|
||||
b = Bookmark{SSH: emptySSH}
|
||||
b = Bookmark{SSH: info}
|
||||
assert.True(t, b.SSHInfoIsEmpty())
|
||||
})
|
||||
|
||||
b.SSH = populatedSSH
|
||||
t.Run("populated", func(t *testing.T) {
|
||||
info := &shared.SSHInfo{
|
||||
Host: "localhost",
|
||||
Port: "8080",
|
||||
User: "postgres",
|
||||
}
|
||||
|
||||
b := Bookmark{SSH: info}
|
||||
assert.False(t, b.SSHInfoIsEmpty())
|
||||
})
|
||||
}
|
||||
|
||||
func Test_ConvertToOptions(t *testing.T) {
|
||||
func TestBookmarkWithVarsConvertToOptions(t *testing.T) {
|
||||
t.Run("literals set", func(t *testing.T) {
|
||||
b := Bookmark{
|
||||
User: "user",
|
||||
UserVar: "",
|
||||
Password: "password",
|
||||
PasswordVar: "",
|
||||
}
|
||||
|
||||
expOpt := command.Options{
|
||||
User: "user",
|
||||
Pass: "password",
|
||||
}
|
||||
|
||||
opt := b.ConvertToOptions()
|
||||
assert.Equal(t, expOpt, opt)
|
||||
})
|
||||
|
||||
t.Run("all set", func(t *testing.T) {
|
||||
b := Bookmark{
|
||||
User: "user",
|
||||
UserVar: "DB_USER",
|
||||
Password: "password",
|
||||
PasswordVar: "DB_PASSWORD",
|
||||
}
|
||||
|
||||
expOpt := command.Options{
|
||||
User: "user",
|
||||
Pass: "password",
|
||||
}
|
||||
|
||||
t.Setenv("DB_USER", "user123")
|
||||
t.Setenv("DB_PASSWORD", "password123")
|
||||
|
||||
opt := b.ConvertToOptions()
|
||||
assert.Equal(t, expOpt, opt)
|
||||
})
|
||||
|
||||
t.Run("env vars set", func(t *testing.T) {
|
||||
b := Bookmark{
|
||||
User: "",
|
||||
UserVar: "DB_USER",
|
||||
Password: "",
|
||||
PasswordVar: "DB_PASSWORD",
|
||||
}
|
||||
|
||||
expOpt := command.Options{
|
||||
User: "user123",
|
||||
Pass: "password123",
|
||||
}
|
||||
|
||||
t.Setenv("DB_USER", "user123")
|
||||
t.Setenv("DB_PASSWORD", "password123")
|
||||
|
||||
opt := b.ConvertToOptions()
|
||||
assert.Equal(t, expOpt, opt)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBookmarkConvertToOptions(t *testing.T) {
|
||||
b := Bookmark{
|
||||
URL: "postgres://username:password@host:port/database?sslmode=disable",
|
||||
Host: "localhost",
|
||||
@@ -125,6 +103,7 @@ func Test_ConvertToOptions(t *testing.T) {
|
||||
Password: "password",
|
||||
Database: "mydatabase",
|
||||
SSLMode: "disable",
|
||||
ReadOnly: true,
|
||||
}
|
||||
|
||||
expOpt := command.Options{
|
||||
@@ -135,7 +114,9 @@ func Test_ConvertToOptions(t *testing.T) {
|
||||
Pass: "password",
|
||||
DbName: "mydatabase",
|
||||
SSLMode: "disable",
|
||||
ReadOnly: true,
|
||||
}
|
||||
|
||||
opt := b.ConvertToOptions()
|
||||
assert.Equal(t, expOpt, opt)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package bookmarks
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
dir string
|
||||
}
|
||||
|
||||
func NewManager(dir string) Manager {
|
||||
return Manager{
|
||||
dir: dir,
|
||||
}
|
||||
}
|
||||
|
||||
func (m Manager) Get(id string) (*Bookmark, error) {
|
||||
bookmarks, err := m.list()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, b := range bookmarks {
|
||||
if b.ID == id {
|
||||
return &b, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("bookmark %v not found", id)
|
||||
}
|
||||
|
||||
func (m Manager) List() ([]Bookmark, error) {
|
||||
return m.list()
|
||||
}
|
||||
|
||||
func (m Manager) ListIDs() ([]string, error) {
|
||||
bookmarks, err := m.list()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ids := make([]string, len(bookmarks))
|
||||
for i, bookmark := range bookmarks {
|
||||
ids[i] = bookmark.ID
|
||||
}
|
||||
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (m Manager) list() ([]Bookmark, error) {
|
||||
result := []Bookmark{}
|
||||
|
||||
if m.dir == "" {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
info, err := os.Stat(m.dir)
|
||||
if err != nil {
|
||||
// Do not fail if base dir does not exists: it's not created by default
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
fmt.Fprintf(os.Stderr, "[WARN] bookmarks dir %s does not exist\n", m.dir)
|
||||
return result, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, fmt.Errorf("path %s is not a directory", m.dir)
|
||||
}
|
||||
|
||||
dirEntries, err := os.ReadDir(m.dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, entry := range dirEntries {
|
||||
name := entry.Name()
|
||||
if filepath.Ext(name) != ".toml" {
|
||||
continue
|
||||
}
|
||||
|
||||
bookmark, err := readBookmark(filepath.Join(m.dir, name))
|
||||
if err != nil {
|
||||
// Do not fail if one of the bookmarks is invalid
|
||||
fmt.Fprintf(os.Stderr, "[WARN] bookmark file %s is invalid: %s\n", name, err)
|
||||
continue
|
||||
}
|
||||
|
||||
result = append(result, bookmark)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func readBookmark(path string) (Bookmark, error) {
|
||||
bookmark := Bookmark{
|
||||
ID: fileBasename(path),
|
||||
}
|
||||
|
||||
_, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
err = fmt.Errorf("bookmark file %s does not exist", path)
|
||||
}
|
||||
return bookmark, err
|
||||
}
|
||||
|
||||
buff, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return bookmark, err
|
||||
}
|
||||
|
||||
_, err = toml.Decode(string(buff), &bookmark)
|
||||
|
||||
if bookmark.Port == 0 {
|
||||
bookmark.Port = 5432
|
||||
}
|
||||
|
||||
// List of all supported postgres modes
|
||||
modes := []string{"disable", "allow", "prefer", "require", "verify-ca", "verify-full"}
|
||||
valid := false
|
||||
|
||||
for _, mode := range modes {
|
||||
if bookmark.SSLMode == mode {
|
||||
valid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to a default mode if mode is not set or invalid
|
||||
// Typical typo: ssl mode set to "disabled"
|
||||
if bookmark.SSLMode == "" || !valid {
|
||||
bookmark.SSLMode = "disable"
|
||||
}
|
||||
|
||||
// Set default SSH port if it's not provided by user
|
||||
if bookmark.SSH != nil && bookmark.SSH.Port == "" {
|
||||
bookmark.SSH.Port = "22"
|
||||
}
|
||||
|
||||
return bookmark, err
|
||||
}
|
||||
|
||||
func fileBasename(path string) string {
|
||||
filename := filepath.Base(path)
|
||||
return strings.Replace(filename, filepath.Ext(path), "", 1)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package bookmarks
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestManagerList(t *testing.T) {
|
||||
examples := []struct {
|
||||
dir string
|
||||
num int
|
||||
err string
|
||||
}{
|
||||
{"../../data", 4, ""},
|
||||
{"../../data/bookmark.toml", 0, "is not a directory"},
|
||||
{"../../data2", 0, ""},
|
||||
{"", 0, ""},
|
||||
}
|
||||
|
||||
for _, ex := range examples {
|
||||
t.Run(ex.dir, func(t *testing.T) {
|
||||
bookmarks, err := NewManager(ex.dir).List()
|
||||
if ex.err != "" {
|
||||
assert.Contains(t, err.Error(), ex.err)
|
||||
}
|
||||
assert.Len(t, bookmarks, ex.num)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerListIDs(t *testing.T) {
|
||||
ids, err := NewManager("../../data").ListIDs()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []string{
|
||||
"bookmark",
|
||||
"bookmark_invalid_ssl",
|
||||
"bookmark_url",
|
||||
"bookmark_with_ssh",
|
||||
}, ids)
|
||||
}
|
||||
|
||||
func TestManagerGet(t *testing.T) {
|
||||
manager := NewManager("../../data")
|
||||
|
||||
b, err := manager.Get("bookmark")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "bookmark", b.ID)
|
||||
|
||||
b, err = manager.Get("foo")
|
||||
assert.Equal(t, "bookmark foo not found", err.Error())
|
||||
assert.Nil(t, b)
|
||||
}
|
||||
|
||||
func Test_fileBasename(t *testing.T) {
|
||||
assert.Equal(t, "filename", fileBasename("filename.toml"))
|
||||
assert.Equal(t, "filename", fileBasename("path/filename.toml"))
|
||||
assert.Equal(t, "filename", fileBasename("~/long/path/filename.toml"))
|
||||
assert.Equal(t, "filename", fileBasename("filename"))
|
||||
}
|
||||
|
||||
func Test_readBookmark(t *testing.T) {
|
||||
t.Run("good", func(t *testing.T) {
|
||||
b, err := readBookmark("../../data/bookmark.toml")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "bookmark", b.ID)
|
||||
assert.Equal(t, "localhost", b.Host)
|
||||
assert.Equal(t, 5432, b.Port)
|
||||
assert.Equal(t, "postgres", b.User)
|
||||
assert.Equal(t, "mydatabase", b.Database)
|
||||
assert.Equal(t, "disable", b.SSLMode)
|
||||
assert.Equal(t, "", b.Password)
|
||||
assert.Equal(t, "", b.URL)
|
||||
})
|
||||
|
||||
t.Run("with url", func(t *testing.T) {
|
||||
b, err := readBookmark("../../data/bookmark_url.toml")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "postgres://username:password@host:port/database?sslmode=disable", b.URL)
|
||||
assert.Equal(t, "", b.Host)
|
||||
assert.Equal(t, 5432, b.Port)
|
||||
assert.Equal(t, "", b.User)
|
||||
assert.Equal(t, "", b.Database)
|
||||
assert.Equal(t, "disable", b.SSLMode)
|
||||
assert.Equal(t, "", b.Password)
|
||||
})
|
||||
|
||||
t.Run("with ssh options", func(t *testing.T) {
|
||||
b, err := readBookmark("../../data/bookmark_with_ssh.toml")
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, b.SSH)
|
||||
|
||||
sshc := b.SSH
|
||||
assert.Equal(t, "ssh-host", sshc.Host)
|
||||
assert.Equal(t, "ssh-user", sshc.User)
|
||||
assert.Equal(t, "ssh-password", sshc.Password)
|
||||
assert.Equal(t, "/path/to/key-file", sshc.Key)
|
||||
assert.Equal(t, "key-file-password", sshc.KeyPassword)
|
||||
})
|
||||
|
||||
t.Run("invalid ssl", func(t *testing.T) {
|
||||
b, err := readBookmark("../../data/bookmark_invalid_ssl.toml")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "disable", b.SSLMode)
|
||||
})
|
||||
|
||||
t.Run("invalid file", func(t *testing.T) {
|
||||
_, err := readBookmark("foobar")
|
||||
assert.Equal(t, "bookmark file foobar does not exist", err.Error())
|
||||
})
|
||||
|
||||
t.Run("invalid syntax", func(t *testing.T) {
|
||||
_, err := readBookmark("../../data/invalid.toml")
|
||||
assert.Equal(t, "toml: line 1: expected '.' or '=', but got 'e' instead", err.Error())
|
||||
})
|
||||
}
|
||||
+121
-66
@@ -1,11 +1,12 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"regexp"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -19,8 +20,10 @@ import (
|
||||
"github.com/sosedoff/pgweb/pkg/client"
|
||||
"github.com/sosedoff/pgweb/pkg/command"
|
||||
"github.com/sosedoff/pgweb/pkg/connection"
|
||||
"github.com/sosedoff/pgweb/pkg/shared"
|
||||
"github.com/sosedoff/pgweb/pkg/metrics"
|
||||
"github.com/sosedoff/pgweb/pkg/queries"
|
||||
"github.com/sosedoff/pgweb/pkg/util"
|
||||
"go.balki.me/anyhttp"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -28,14 +31,11 @@ var (
|
||||
options command.Options
|
||||
|
||||
readonlyWarning = `
|
||||
------------------------------------------------------
|
||||
SECURITY WARNING: You are running pgweb in read-only mode.
|
||||
This mode is designed for environments where users could potentially delete / change data.
|
||||
For proper read-only access please follow postgresql role management documentation.
|
||||
------------------------------------------------------`
|
||||
|
||||
regexErrConnectionRefused = regexp.MustCompile(`(connection|actively) refused`)
|
||||
regexErrAuthFailed = regexp.MustCompile(`authentication failed`)
|
||||
--------------------------------------------------------------------------------
|
||||
SECURITY WARNING: You are running Pgweb in read-only mode.
|
||||
This mode is designed for environments where users could potentially delete or change data.
|
||||
For proper read-only access please follow PostgreSQL role management documentation.
|
||||
--------------------------------------------------------------------------------`
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -47,30 +47,14 @@ func exitWithMessage(message string) {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func initClientUsingBookmark(bookmarkPath, bookmarkName string) (*client.Client, error) {
|
||||
bookmark, err := bookmarks.GetBookmark(bookmarkPath, bookmarkName)
|
||||
func initClientUsingBookmark(baseDir, bookmarkName string) (*client.Client, error) {
|
||||
manager := bookmarks.NewManager(baseDir)
|
||||
bookmark, err := manager.Get(bookmarkName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
opt := bookmark.ConvertToOptions()
|
||||
var connStr string
|
||||
|
||||
if opt.URL != "" { // if the bookmark has url set, use it
|
||||
connStr = opt.URL
|
||||
} else {
|
||||
connStr, err = connection.BuildStringFromOptions(opt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error building connection string: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
var ssh *shared.SSHInfo
|
||||
if !bookmark.SSHInfoIsEmpty() {
|
||||
ssh = bookmark.SSH
|
||||
}
|
||||
|
||||
return client.NewFromUrl(connStr, ssh)
|
||||
return client.NewFromBookmark(bookmark)
|
||||
}
|
||||
|
||||
func initClient() {
|
||||
@@ -82,7 +66,7 @@ func initClient() {
|
||||
var err error
|
||||
|
||||
if options.Bookmark != "" {
|
||||
cl, err = initClientUsingBookmark(bookmarks.Path(options.BookmarksDir), options.Bookmark)
|
||||
cl, err = initClientUsingBookmark(options.BookmarksDir, options.Bookmark)
|
||||
} else {
|
||||
cl, err = client.New()
|
||||
}
|
||||
@@ -91,35 +75,20 @@ func initClient() {
|
||||
}
|
||||
|
||||
if command.Opts.Debug {
|
||||
fmt.Println("Server connection string:", cl.ConnectionString)
|
||||
fmt.Println("Opening database connection using string:", cl.ConnectionString)
|
||||
}
|
||||
|
||||
retryCount := command.Opts.RetryCount
|
||||
retryDelay := time.Second * time.Duration(command.Opts.RetryDelay)
|
||||
|
||||
fmt.Println("Connecting to server...")
|
||||
if err := cl.Test(); err != nil {
|
||||
msg := err.Error()
|
||||
|
||||
// Check if we're trying to connect to the default database.
|
||||
if command.Opts.DbName == "" && command.Opts.URL == "" {
|
||||
// If database does not exist, allow user to connect from the UI.
|
||||
if strings.Contains(msg, "database") && strings.Contains(msg, "does not exist") {
|
||||
fmt.Println("Error:", msg)
|
||||
abort, err := testClient(cl, int(retryCount), retryDelay)
|
||||
if err != nil {
|
||||
if abort {
|
||||
exitWithMessage(err.Error())
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
// Do not bail if local server is not running.
|
||||
if regexErrConnectionRefused.MatchString(msg) {
|
||||
fmt.Println("Error:", msg)
|
||||
return
|
||||
}
|
||||
|
||||
// Do not bail if local auth is invalid
|
||||
if regexErrAuthFailed.MatchString(msg) {
|
||||
fmt.Println("Error:", msg)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
exitWithMessage(msg)
|
||||
}
|
||||
|
||||
if !command.Opts.Sessions {
|
||||
@@ -173,9 +142,33 @@ func initOptions() {
|
||||
}
|
||||
}
|
||||
|
||||
configureLocalQueryStore()
|
||||
printVersion()
|
||||
}
|
||||
|
||||
func configureLocalQueryStore() {
|
||||
if options.Sessions || options.QueriesDir == "" {
|
||||
return
|
||||
}
|
||||
|
||||
stat, err := os.Stat(options.QueriesDir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
logger.Debugf("local queries directory %q does not exist, disabling feature", options.QueriesDir)
|
||||
} else {
|
||||
logger.Debugf("local queries feature disabled due to error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !stat.IsDir() {
|
||||
logger.Debugf("local queries path %q is not a directory", options.QueriesDir)
|
||||
return
|
||||
}
|
||||
|
||||
api.QueryStore = queries.NewStore(options.QueriesDir)
|
||||
}
|
||||
|
||||
func configureLogger(opts command.Options) error {
|
||||
if options.Debug {
|
||||
logger.SetLevel(logrus.DebugLevel)
|
||||
@@ -203,7 +196,7 @@ func printVersion() {
|
||||
fmt.Println(command.VersionString())
|
||||
}
|
||||
|
||||
func startServer() {
|
||||
func startServer() *anyhttp.ServerCtx {
|
||||
router := gin.New()
|
||||
router.Use(api.RequestLogger(logger))
|
||||
router.Use(gin.Recovery())
|
||||
@@ -216,24 +209,37 @@ func startServer() {
|
||||
|
||||
api.SetLogger(logger)
|
||||
api.SetupRoutes(router)
|
||||
api.SetupMetrics(router)
|
||||
|
||||
fmt.Println("Starting server...")
|
||||
go func() {
|
||||
err := router.Run(fmt.Sprintf("%v:%v", options.HTTPHost, options.HTTPPort))
|
||||
address := fmt.Sprintf("%v:%v", options.HTTPHost, options.HTTPPort)
|
||||
if options.HTTPAddr != "" {
|
||||
address = options.HTTPAddr
|
||||
}
|
||||
|
||||
metrics.SetHealthy(true)
|
||||
|
||||
ctx, err := anyhttp.Serve(address, router.Handler())
|
||||
if err != nil {
|
||||
fmt.Println("Cant start server:", err)
|
||||
fmt.Println("Can't start server:", err)
|
||||
if strings.Contains(err.Error(), "address already in use") {
|
||||
openPage()
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
return ctx
|
||||
}
|
||||
|
||||
func handleSignals() {
|
||||
c := make(chan os.Signal, 1)
|
||||
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
||||
<-c
|
||||
func startMetricsServer() {
|
||||
serverAddr := fmt.Sprintf("%v:%v", command.Opts.HTTPHost, command.Opts.HTTPPort)
|
||||
if options.MetricsAddr == serverAddr {
|
||||
return
|
||||
}
|
||||
|
||||
err := metrics.StartServer(logger, options.MetricsPath, options.MetricsAddr)
|
||||
if err != nil {
|
||||
logger.WithError(err).Fatal("unable to start prometheus metrics server")
|
||||
}
|
||||
}
|
||||
|
||||
func openPage() {
|
||||
@@ -255,6 +261,39 @@ func openPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// testClient attempts to establish a database connection until it succeeds or
|
||||
// give up after certain number of retries. Retries only available when database
|
||||
// name or a connection string is provided.
|
||||
func testClient(cl *client.Client, retryCount int, retryDelay time.Duration) (abort bool, err error) {
|
||||
usingDefaultDB := command.Opts.DbName == "" && command.Opts.URL == ""
|
||||
|
||||
for {
|
||||
err = cl.Test()
|
||||
if err == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Continue normal start up if can't connect locally without database details.
|
||||
if usingDefaultDB {
|
||||
if errors.Is(err, client.ErrConnectionRefused) ||
|
||||
errors.Is(err, client.ErrAuthFailed) ||
|
||||
errors.Is(err, client.ErrDatabaseNotExist) {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
// Only retry if can't establish connection to the server.
|
||||
if errors.Is(err, client.ErrConnectionRefused) && retryCount > 0 {
|
||||
fmt.Printf("Connection error: %v, retrying in %v (%d remaining)\n", err, retryDelay, retryCount)
|
||||
retryCount--
|
||||
<-time.After(retryDelay)
|
||||
continue
|
||||
}
|
||||
|
||||
return true, err
|
||||
}
|
||||
}
|
||||
|
||||
func Run() {
|
||||
initOptions()
|
||||
initClient()
|
||||
@@ -282,7 +321,23 @@ func Run() {
|
||||
}
|
||||
}
|
||||
|
||||
startServer()
|
||||
// Start a separate metrics http server. If metrics addr is not provided, we
|
||||
// add the metrics endpoint in the existing application server (see api.go).
|
||||
if options.MetricsEnabled && options.MetricsAddr != "" {
|
||||
go startMetricsServer()
|
||||
}
|
||||
|
||||
serverCtx := startServer()
|
||||
|
||||
openPage()
|
||||
handleSignals()
|
||||
|
||||
c := make(chan os.Signal, 1)
|
||||
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
||||
select {
|
||||
case doneErr := <-serverCtx.Done:
|
||||
logger.Infoln("idle server. shutting down error: ", doneErr)
|
||||
case <-c:
|
||||
logger.Infoln("received signal. shutting down")
|
||||
serverCtx.Shutdown(context.TODO())
|
||||
}
|
||||
}
|
||||
|
||||
+180
-14
@@ -7,12 +7,14 @@ import (
|
||||
"log"
|
||||
neturl "net/url"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"github.com/sosedoff/pgweb/pkg/bookmarks"
|
||||
"github.com/sosedoff/pgweb/pkg/command"
|
||||
"github.com/sosedoff/pgweb/pkg/connection"
|
||||
"github.com/sosedoff/pgweb/pkg/history"
|
||||
@@ -20,6 +22,18 @@ import (
|
||||
"github.com/sosedoff/pgweb/pkg/statements"
|
||||
)
|
||||
|
||||
var (
|
||||
regexErrAuthFailed = regexp.MustCompile(`(authentication failed|role "(.*)" does not exist)`)
|
||||
regexErrConnectionRefused = regexp.MustCompile(`(connection|actively) refused`)
|
||||
regexErrDatabaseNotExist = regexp.MustCompile(`database "(.*)" does not exist`)
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAuthFailed = errors.New("authentication failed")
|
||||
ErrConnectionRefused = errors.New("connection refused")
|
||||
ErrDatabaseNotExist = errors.New("database does not exist")
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
db *sqlx.DB
|
||||
tunnel *Tunnel
|
||||
@@ -27,21 +41,13 @@ type Client struct {
|
||||
serverType string
|
||||
lastQueryTime time.Time
|
||||
queryTimeout time.Duration
|
||||
readonly bool
|
||||
closed bool
|
||||
External bool `json:"external"`
|
||||
History []history.Record `json:"history"`
|
||||
ConnectionString string `json:"connection_string"`
|
||||
}
|
||||
|
||||
// Struct to hold table rows browsing options
|
||||
type RowsOptions struct {
|
||||
Where string // Custom filter
|
||||
Offset int // Number of rows to skip
|
||||
Limit int // Number of rows to fetch
|
||||
SortColumn string // Column to sort by
|
||||
SortOrder string // Sort direction (ASC, DESC)
|
||||
}
|
||||
|
||||
func getSchemaAndTable(str string) (string, string) {
|
||||
chunks := strings.Split(str, ".")
|
||||
if len(chunks) == 1 {
|
||||
@@ -77,7 +83,10 @@ func New() (*Client, error) {
|
||||
}
|
||||
|
||||
func NewFromUrl(url string, sshInfo *shared.SSHInfo) (*Client, error) {
|
||||
var tunnel *Tunnel
|
||||
var (
|
||||
tunnel *Tunnel
|
||||
err error
|
||||
)
|
||||
|
||||
if sshInfo != nil {
|
||||
if command.Opts.DisableSSH {
|
||||
@@ -87,7 +96,7 @@ func NewFromUrl(url string, sshInfo *shared.SSHInfo) (*Client, error) {
|
||||
fmt.Println("Opening SSH tunnel for:", sshInfo)
|
||||
}
|
||||
|
||||
tunnel, err := NewTunnel(sshInfo, url)
|
||||
tunnel, err = NewTunnel(sshInfo, url)
|
||||
if err != nil {
|
||||
tunnel.Close()
|
||||
return nil, err
|
||||
@@ -137,6 +146,39 @@ func NewFromUrl(url string, sshInfo *shared.SSHInfo) (*Client, error) {
|
||||
return &client, nil
|
||||
}
|
||||
|
||||
func NewFromBookmark(bookmark *bookmarks.Bookmark) (*Client, error) {
|
||||
var (
|
||||
connStr string
|
||||
err error
|
||||
)
|
||||
|
||||
options := bookmark.ConvertToOptions()
|
||||
if options.URL != "" {
|
||||
connStr = options.URL
|
||||
} else {
|
||||
connStr, err = connection.BuildStringFromOptions(options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var sshInfo *shared.SSHInfo
|
||||
if !bookmark.SSHInfoIsEmpty() {
|
||||
sshInfo = bookmark.SSH
|
||||
}
|
||||
|
||||
client, err := NewFromUrl(connStr, sshInfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if bookmark.ReadOnly {
|
||||
client.readonly = true
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (client *Client) init() {
|
||||
if command.Opts.QueryTimeout > 0 {
|
||||
client.queryTimeout = time.Second * time.Duration(command.Opts.QueryTimeout)
|
||||
@@ -160,11 +202,66 @@ func (client *Client) setServerVersion() {
|
||||
}
|
||||
|
||||
func (client *Client) Test() error {
|
||||
return client.db.Ping()
|
||||
// NOTE: This is a different timeout defined in CLI OpenTimeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := client.db.PingContext(ctx)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
errMsg := err.Error()
|
||||
|
||||
if regexErrConnectionRefused.MatchString(errMsg) {
|
||||
return ErrConnectionRefused
|
||||
}
|
||||
if regexErrAuthFailed.MatchString(errMsg) {
|
||||
return ErrAuthFailed
|
||||
}
|
||||
if regexErrDatabaseNotExist.MatchString(errMsg) {
|
||||
return ErrDatabaseNotExist
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (client *Client) TestWithTimeout(timeout time.Duration) (result error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
// Check connection status right away without waiting for the ticker to kick in.
|
||||
// We're expecting to get "connection refused" here for the most part.
|
||||
if err := client.db.PingContext(ctx); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(250 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
result = client.db.PingContext(ctx)
|
||||
if result == nil {
|
||||
return
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (client *Client) Info() (*Result, error) {
|
||||
return client.query(statements.Info)
|
||||
result, err := client.query(statements.Info)
|
||||
if err != nil {
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, "inet_") && (strings.Contains(msg, "not supported") || strings.Contains(msg, "permission denied")) {
|
||||
// Fetch client information without inet_ function calls
|
||||
result, err = client.query(statements.InfoSimple)
|
||||
}
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (client *Client) Databases() ([]string, error) {
|
||||
@@ -285,6 +382,14 @@ func (client *Client) TableConstraints(table string) (*Result, error) {
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (client *Client) TablesStats() (*Result, error) {
|
||||
return client.query(statements.TablesStats)
|
||||
}
|
||||
|
||||
func (client *Client) ServerSettings() (*Result, error) {
|
||||
return client.query(statements.Settings)
|
||||
}
|
||||
|
||||
// Returns all active queriers on the server
|
||||
func (client *Client) Activity() (*Result, error) {
|
||||
if client.serverType == cockroachType {
|
||||
@@ -344,7 +449,9 @@ func (client *Client) exec(query string, args ...interface{}) (*Result, error) {
|
||||
ctx, cancel := client.context()
|
||||
defer cancel()
|
||||
|
||||
queryStart := time.Now()
|
||||
res, err := client.db.ExecContext(ctx, query, args...)
|
||||
queryFinish := time.Now()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -359,6 +466,13 @@ func (client *Client) exec(query string, args ...interface{}) (*Result, error) {
|
||||
Rows: []Row{
|
||||
{affected},
|
||||
},
|
||||
Stats: &ResultStats{
|
||||
ColumnsCount: 1,
|
||||
RowsCount: 1,
|
||||
QueryStartTime: queryStart.UTC(),
|
||||
QueryFinishTime: queryFinish.UTC(),
|
||||
QueryDuration: queryFinish.Sub(queryStart).Milliseconds(),
|
||||
},
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
@@ -376,7 +490,7 @@ func (client *Client) query(query string, args ...interface{}) (*Result, error)
|
||||
|
||||
// We're going to force-set transaction mode on every query.
|
||||
// This is needed so that default mode could not be changed by user.
|
||||
if command.Opts.ReadOnly {
|
||||
if command.Opts.ReadOnly || client.readonly {
|
||||
if err := client.SetReadOnlyMode(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -395,7 +509,9 @@ func (client *Client) query(query string, args ...interface{}) (*Result, error)
|
||||
ctx, cancel := client.context()
|
||||
defer cancel()
|
||||
|
||||
queryStart := time.Now()
|
||||
rows, err := client.db.QueryxContext(ctx, query, args...)
|
||||
queryFinish := time.Now()
|
||||
if err != nil {
|
||||
if command.Opts.Debug {
|
||||
log.Println("Failed query:", query, "\nArgs:", args)
|
||||
@@ -439,6 +555,14 @@ func (client *Client) query(query string, args ...interface{}) (*Result, error)
|
||||
}
|
||||
}
|
||||
|
||||
result.Stats = &ResultStats{
|
||||
ColumnsCount: len(cols),
|
||||
RowsCount: len(result.Rows),
|
||||
QueryStartTime: queryStart.UTC(),
|
||||
QueryFinishTime: queryFinish.UTC(),
|
||||
QueryDuration: queryFinish.Sub(queryStart).Milliseconds(),
|
||||
}
|
||||
|
||||
result.PostProcess()
|
||||
|
||||
return &result, nil
|
||||
@@ -451,6 +575,7 @@ func (client *Client) Close() error {
|
||||
}
|
||||
defer func() {
|
||||
client.closed = true
|
||||
client.tunnel = nil
|
||||
}()
|
||||
|
||||
if client.tunnel != nil {
|
||||
@@ -512,3 +637,44 @@ func (client *Client) hasHistoryRecord(query string) bool {
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
type ConnContext struct {
|
||||
Host string
|
||||
User string
|
||||
Database string
|
||||
Mode string
|
||||
}
|
||||
|
||||
func (c ConnContext) String() string {
|
||||
return fmt.Sprintf(
|
||||
"host=%q user=%q database=%q mode=%q",
|
||||
c.Host, c.User, c.Database, c.Mode,
|
||||
)
|
||||
}
|
||||
|
||||
// ConnContext returns information about current database connection
|
||||
func (client *Client) GetConnContext() (*ConnContext, error) {
|
||||
url, err := neturl.Parse(client.ConnectionString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
|
||||
defer cancel()
|
||||
|
||||
connCtx := ConnContext{
|
||||
Host: url.Hostname(),
|
||||
Mode: "default",
|
||||
}
|
||||
|
||||
if command.Opts.ReadOnly {
|
||||
connCtx.Mode = "readonly"
|
||||
}
|
||||
|
||||
row := client.db.QueryRowContext(ctx, "SELECT current_user, current_database()")
|
||||
if err := row.Scan(&connCtx.User, &connCtx.Database); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &connCtx, nil
|
||||
}
|
||||
|
||||
+154
-4
@@ -11,9 +11,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sosedoff/pgweb/pkg/command"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/sosedoff/pgweb/pkg/command"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -56,7 +57,9 @@ func assertMatches(t *testing.T, expected, src []string) {
|
||||
|
||||
func pgVersion() (int, int) {
|
||||
var major, minor int
|
||||
fmt.Sscanf(os.Getenv("PGVERSION"), "%d.%d", &major, &minor)
|
||||
if _, err := fmt.Sscanf(os.Getenv("PGVERSION"), "%d.%d", &major, &minor); err != nil {
|
||||
log.Println("[warn] unable to read value of PGVERSION env var")
|
||||
}
|
||||
return major, minor
|
||||
}
|
||||
|
||||
@@ -199,10 +202,50 @@ func testClientIdleTime(t *testing.T) {
|
||||
}
|
||||
|
||||
func testTest(t *testing.T) {
|
||||
assert.NoError(t, testClient.Test())
|
||||
examples := []struct {
|
||||
name string
|
||||
input string
|
||||
err error
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
input: fmt.Sprintf("postgres://%s@%s:%s/%s?sslmode=disable", serverUser, serverHost, serverPort, serverDatabase),
|
||||
err: nil,
|
||||
},
|
||||
{
|
||||
name: "connection refused",
|
||||
input: "postgresql://localhost:5433/dbname",
|
||||
err: ErrConnectionRefused,
|
||||
},
|
||||
{
|
||||
name: "invalid user",
|
||||
input: fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable", "foo", serverPassword, serverHost, serverPort, serverDatabase),
|
||||
err: ErrAuthFailed,
|
||||
},
|
||||
{
|
||||
name: "invalid password",
|
||||
input: fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable", serverUser, "foo", serverHost, serverPort, serverDatabase),
|
||||
err: ErrAuthFailed,
|
||||
},
|
||||
{
|
||||
name: "invalid database",
|
||||
input: fmt.Sprintf("postgres://%s@%s:%s/%s?sslmode=disable", serverUser, serverHost, serverPort, "foo"),
|
||||
err: ErrDatabaseNotExist,
|
||||
},
|
||||
}
|
||||
|
||||
for _, ex := range examples {
|
||||
t.Run(ex.name, func(t *testing.T) {
|
||||
conn, err := NewFromUrl(ex.input, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, ex.err, conn.Test())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testInfo(t *testing.T) {
|
||||
t.Run("normal", func(t *testing.T) {
|
||||
expected := []string{
|
||||
"session_user",
|
||||
"current_user",
|
||||
@@ -218,6 +261,41 @@ func testInfo(t *testing.T) {
|
||||
res, err := testClient.Info()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expected, res.Columns)
|
||||
})
|
||||
|
||||
t.Run("with restrictions", func(t *testing.T) {
|
||||
expected := []string{
|
||||
"session_user",
|
||||
"current_user",
|
||||
"current_database",
|
||||
"current_schemas",
|
||||
"version",
|
||||
}
|
||||
|
||||
// Prepare a new user and database
|
||||
testClient.db.MustExec("DROP DATABASE IF EXISTS testdb")
|
||||
testClient.db.Exec("DROP OWNED BY IF EXISTS testuser") //nolint:all
|
||||
testClient.db.MustExec("DROP ROLE IF EXISTS testuser")
|
||||
testClient.db.MustExec("CREATE ROLE testuser WITH PASSWORD 'secret' LOGIN NOSUPERUSER NOINHERIT")
|
||||
testClient.db.MustExec("CREATE DATABASE testdb OWNER testuser")
|
||||
|
||||
// Disable access to inet_ calls for new user
|
||||
url := fmt.Sprintf("postgres://%s:@%s:%s/testdb?sslmode=disable", serverUser, serverHost, serverPort)
|
||||
client, err := NewFromUrl(url, nil)
|
||||
assert.NoError(t, err)
|
||||
client.db.MustExec("REVOKE EXECUTE ON FUNCTION inet_client_addr() FROM PUBLIC")
|
||||
assert.NoError(t, client.Close())
|
||||
|
||||
// Connect using new user
|
||||
url = fmt.Sprintf("postgres://testuser:secret@%s:%s/testdb?sslmode=disable", serverHost, serverPort)
|
||||
client, err = NewFromUrl(url, nil)
|
||||
assert.NoError(t, err)
|
||||
defer client.Close()
|
||||
|
||||
res, err := client.Info()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expected, res.Columns)
|
||||
})
|
||||
}
|
||||
|
||||
func testActivity(t *testing.T) {
|
||||
@@ -234,6 +312,12 @@ func testDatabases(t *testing.T) {
|
||||
assertMatches(t, []string{"booktown", "postgres"}, res)
|
||||
}
|
||||
|
||||
func testSchemas(t *testing.T) {
|
||||
res, err := testClient.Schemas()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []string{"public"}, res)
|
||||
}
|
||||
|
||||
func testObjects(t *testing.T) {
|
||||
res, err := testClient.Objects()
|
||||
objects := ObjectsFromResult(res)
|
||||
@@ -597,6 +681,68 @@ func testReadOnlyMode(t *testing.T) {
|
||||
|
||||
_, err = client.Query("/* CREATE TABLE foobar(id integer); */ SELECT 'foo';")
|
||||
assert.NoError(t, err)
|
||||
|
||||
t.Run("with local readonly flag", func(t *testing.T) {
|
||||
command.Opts.ReadOnly = false
|
||||
client.readonly = true
|
||||
|
||||
_, err := client.Query("INSERT INTO foobar(id) VALUES(1)")
|
||||
assert.Error(t, err, "query contains keywords not allowed in read-only mode")
|
||||
})
|
||||
}
|
||||
|
||||
func testTablesStats(t *testing.T) {
|
||||
columns := []string{
|
||||
"schema_name",
|
||||
"table_name",
|
||||
"total_size",
|
||||
"data_size",
|
||||
"index_size",
|
||||
"estimated_rows_count",
|
||||
"estimated_rows",
|
||||
"index_to_data_ratio",
|
||||
"indexes_count",
|
||||
"columns_count",
|
||||
}
|
||||
|
||||
result, err := testClient.TablesStats()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, columns, result.Columns)
|
||||
}
|
||||
|
||||
func testConnContext(t *testing.T) {
|
||||
result, err := testClient.GetConnContext()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "localhost", result.Host)
|
||||
assert.Equal(t, "postgres", result.User)
|
||||
assert.Equal(t, "booktown", result.Database)
|
||||
assert.Equal(t, "default", result.Mode)
|
||||
}
|
||||
|
||||
func testServerSettings(t *testing.T) {
|
||||
expectedColumns := []string{
|
||||
"name",
|
||||
"setting",
|
||||
"unit",
|
||||
"category",
|
||||
"short_desc",
|
||||
"extra_desc",
|
||||
"context",
|
||||
"vartype",
|
||||
"source",
|
||||
"min_val",
|
||||
"max_val",
|
||||
"enumvals",
|
||||
"boot_val",
|
||||
"reset_val",
|
||||
"sourcefile",
|
||||
"sourceline",
|
||||
"pending_restart",
|
||||
}
|
||||
|
||||
result, err := testClient.ServerSettings()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expectedColumns, result.Columns)
|
||||
}
|
||||
|
||||
func TestAll(t *testing.T) {
|
||||
@@ -617,6 +763,7 @@ func TestAll(t *testing.T) {
|
||||
testInfo(t)
|
||||
testActivity(t)
|
||||
testDatabases(t)
|
||||
testSchemas(t)
|
||||
testObjects(t)
|
||||
testTable(t)
|
||||
testTableRows(t)
|
||||
@@ -635,6 +782,9 @@ func TestAll(t *testing.T) {
|
||||
testHistory(t)
|
||||
testReadOnlyMode(t)
|
||||
testDumpExport(t)
|
||||
testTablesStats(t)
|
||||
testConnContext(t)
|
||||
testServerSettings(t)
|
||||
|
||||
teardownClient()
|
||||
teardown(t, true)
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
// BinaryEncodingFormat specifies the default serialization format of binary data
|
||||
// BinaryCodec sets the serialization format of binary data
|
||||
BinaryCodec = CodecBase64
|
||||
)
|
||||
|
||||
|
||||
+25
-9
@@ -22,8 +22,18 @@ const (
|
||||
)
|
||||
|
||||
type (
|
||||
// Row represents a single row of data
|
||||
Row []interface{}
|
||||
|
||||
// RowsOptions contains a list of parameters for table browsing requests
|
||||
RowsOptions struct {
|
||||
Where string // Custom filter
|
||||
Offset int // Number of rows to skip
|
||||
Limit int // Number of rows to fetch
|
||||
SortColumn string // Column to sort by
|
||||
SortOrder string // Sort direction (ASC, DESC)
|
||||
}
|
||||
|
||||
Pagination struct {
|
||||
Rows int64 `json:"rows_count"`
|
||||
Page int64 `json:"page"`
|
||||
@@ -35,6 +45,16 @@ type (
|
||||
Pagination *Pagination `json:"pagination,omitempty"`
|
||||
Columns []string `json:"columns"`
|
||||
Rows []Row `json:"rows"`
|
||||
Stats *ResultStats `json:"stats,omitempty"`
|
||||
}
|
||||
|
||||
ResultStats struct {
|
||||
ColumnsCount int `json:"columns_count"`
|
||||
RowsCount int `json:"rows_count"`
|
||||
RowsAffected int64 `json:"rows_affected"`
|
||||
QueryStartTime time.Time `json:"query_start_time"`
|
||||
QueryFinishTime time.Time `json:"query_finish_time"`
|
||||
QueryDuration int64 `json:"query_duration_ms"`
|
||||
}
|
||||
|
||||
Object struct {
|
||||
@@ -94,16 +114,15 @@ func (res *Result) PostProcess() {
|
||||
}
|
||||
|
||||
func (res *Result) Format() []map[string]interface{} {
|
||||
var items []map[string]interface{}
|
||||
items := make([]map[string]interface{}, len(res.Rows))
|
||||
|
||||
for _, row := range res.Rows {
|
||||
for rowIdx, row := range res.Rows {
|
||||
item := make(map[string]interface{})
|
||||
|
||||
for i, c := range res.Columns {
|
||||
item[c] = row[i]
|
||||
}
|
||||
|
||||
items = append(items, item)
|
||||
items[rowIdx] = item
|
||||
}
|
||||
|
||||
return items
|
||||
@@ -121,20 +140,17 @@ func (res *Result) CSV() []byte {
|
||||
record := make([]string, len(res.Columns))
|
||||
|
||||
for i, item := range row {
|
||||
if item != nil {
|
||||
switch v := item.(type) {
|
||||
case time.Time:
|
||||
record[i] = v.Format("2006-01-02 15:04:05")
|
||||
case nil:
|
||||
record[i] = ""
|
||||
default:
|
||||
record[i] = fmt.Sprintf("%v", item)
|
||||
}
|
||||
} else {
|
||||
record[i] = ""
|
||||
}
|
||||
}
|
||||
|
||||
err := writer.Write(record)
|
||||
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
break
|
||||
|
||||
@@ -2,6 +2,7 @@ package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -51,17 +52,20 @@ func TestPostProcess(t *testing.T) {
|
||||
|
||||
func TestCSV(t *testing.T) {
|
||||
result := Result{
|
||||
Columns: []string{"id", "name", "email"},
|
||||
Columns: []string{"id", "name", "email", "extra"},
|
||||
Rows: []Row{
|
||||
{1, "John", "john@example.com"},
|
||||
{2, "Bob", "bob@example.com"},
|
||||
{1, "John", "john@example.com", "data"},
|
||||
{2, "Bob", "bob@example.com", nil},
|
||||
},
|
||||
}
|
||||
|
||||
expected := "id,name,email\n1,John,john@example.com\n2,Bob,bob@example.com\n"
|
||||
output := string(result.CSV())
|
||||
expected := strings.Join([]string{
|
||||
"id,name,email,extra",
|
||||
"1,John,john@example.com,data",
|
||||
"2,Bob,bob@example.com,",
|
||||
}, "\n") + "\n"
|
||||
|
||||
assert.Equal(t, expected, output)
|
||||
assert.Equal(t, expected, string(result.CSV()))
|
||||
}
|
||||
|
||||
func TestJSON(t *testing.T) {
|
||||
@@ -116,3 +120,20 @@ func TestJSON(t *testing.T) {
|
||||
assert.Equal(t, `[{"value":"2022-01-01T00:00:00Z"},{"value":"9022-01-01T00:00:00Z"},{"value":"ERR: INVALID_DATE"}]`, string(result.JSON()))
|
||||
})
|
||||
}
|
||||
|
||||
func TestResultFormat(t *testing.T) {
|
||||
result := Result{
|
||||
Columns: []string{"col1", "col2", "col3", "col4"},
|
||||
Rows: []Row{
|
||||
{"1", "2", "3", nil},
|
||||
{"4", "5", "6", nil},
|
||||
},
|
||||
}
|
||||
|
||||
expected := []map[string]interface{}{
|
||||
{"col1": "1", "col2": "2", "col3": "3", "col4": nil},
|
||||
{"col1": "4", "col2": "5", "col3": "6", "col4": nil},
|
||||
}
|
||||
|
||||
assert.Equal(t, expected, result.Format())
|
||||
}
|
||||
|
||||
+11
-11
@@ -8,6 +8,7 @@ import (
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -35,8 +36,8 @@ type Tunnel struct {
|
||||
Listener *net.TCPListener
|
||||
}
|
||||
|
||||
func privateKeyPath() string {
|
||||
return os.Getenv("HOME") + "/.ssh/id_rsa"
|
||||
func defaultKeyPath() string {
|
||||
return filepath.Join(os.Getenv("HOME"), ".ssh/id_rsa")
|
||||
}
|
||||
|
||||
func expandKeyPath(path string) string {
|
||||
@@ -59,14 +60,13 @@ func parsePrivateKey(keyPath string, keyPass string) (ssh.Signer, error) {
|
||||
}
|
||||
|
||||
signer, err := ssh.ParsePrivateKey(buff)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "cannot decode encrypted private keys") {
|
||||
if _, ok := err.(*ssh.PassphraseMissingError); ok {
|
||||
if keyPass == "" {
|
||||
return nil, errors.New("SSH key password is not provided")
|
||||
return nil, errors.New("ssh key password is not provided")
|
||||
}
|
||||
return sshkeys.ParseEncryptedPrivateKey(buff, []byte(keyPass))
|
||||
}
|
||||
}
|
||||
|
||||
return signer, err
|
||||
}
|
||||
|
||||
@@ -76,13 +76,13 @@ func makeConfig(info *shared.SSHInfo) (*ssh.ClientConfig, error) {
|
||||
// Try to use user-provided key, fallback to system default key
|
||||
keyPath := info.Key
|
||||
if keyPath == "" {
|
||||
keyPath = privateKeyPath()
|
||||
keyPath = defaultKeyPath()
|
||||
} else {
|
||||
keyPath = expandKeyPath(keyPath)
|
||||
}
|
||||
|
||||
if !fileExists(keyPath) {
|
||||
return nil, errors.New("ssh public key not found at " + keyPath)
|
||||
return nil, fmt.Errorf("ssh public key not found at path %q", keyPath)
|
||||
}
|
||||
|
||||
// Append public key authentication method
|
||||
@@ -130,11 +130,11 @@ func (tunnel *Tunnel) handleConnection(local net.Conn) {
|
||||
return
|
||||
}
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
wg := &sync.WaitGroup{}
|
||||
wg.Add(2)
|
||||
|
||||
go tunnel.copy(&wg, local, remote)
|
||||
go tunnel.copy(&wg, remote, local)
|
||||
go tunnel.copy(wg, local, remote)
|
||||
go tunnel.copy(wg, remote, local)
|
||||
|
||||
wg.Wait()
|
||||
local.Close()
|
||||
|
||||
+3
-3
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
var (
|
||||
// List of keywords that are not allowed in read-only mode
|
||||
reRestrictedKeywords = regexp.MustCompile(`(?mi)\s?(CREATE|INSERT|DROP|DELETE|TRUNCATE|GRANT|OPEN|IMPORT|COPY)\s`)
|
||||
reRestrictedKeywords = regexp.MustCompile(`(?mi)\s?(CREATE|INSERT|UPDATE|DROP|DELETE|TRUNCATE|GRANT|OPEN|IMPORT|COPY)\s`)
|
||||
|
||||
// Comment regular expressions
|
||||
reSlashComment = regexp.MustCompile(`(?m)/\*.+\*/`)
|
||||
@@ -28,9 +28,9 @@ var (
|
||||
// Example: 10.2.3.1 -> 10.2
|
||||
func getMajorMinorVersion(str string) (major int, minor int) {
|
||||
chunks := strings.Split(str, ".")
|
||||
fmt.Sscanf(chunks[0], "%d", &major)
|
||||
fmt.Sscanf(chunks[0], "%d", &major) //nolint
|
||||
if len(chunks) > 1 {
|
||||
fmt.Sscanf(chunks[1], "%d", &minor)
|
||||
fmt.Sscanf(chunks[1], "%d", &minor) //nolint
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
+65
-2
@@ -5,9 +5,12 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgpassfile"
|
||||
"github.com/jessevdk/go-flags"
|
||||
"github.com/mitchellh/go-homedir"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
@@ -21,16 +24,22 @@ type Options struct {
|
||||
Debug bool `short:"d" long:"debug" description:"Enable debugging mode"`
|
||||
LogLevel string `long:"log-level" description:"Logging level" default:"info"`
|
||||
LogFormat string `long:"log-format" description:"Logging output format" default:"text"`
|
||||
LogForwardedUser bool `long:"log-forwarded-user" description:"Log user information available in X-Forwarded-User/Email headers"`
|
||||
URL string `long:"url" description:"Database connection string"`
|
||||
Host string `long:"host" description:"Server hostname or IP" default:"localhost"`
|
||||
Port int `long:"port" description:"Server port" default:"5432"`
|
||||
User string `long:"user" description:"Database user"`
|
||||
Pass string `long:"pass" description:"Password for user"`
|
||||
Passfile string `long:"passfile" description:"Local passwords file location"`
|
||||
DbName string `long:"db" description:"Database name"`
|
||||
SSLMode string `long:"ssl" description:"SSL mode"`
|
||||
SSLRootCert string `long:"ssl-rootcert" description:"SSL certificate authority file"`
|
||||
SSLCert string `long:"ssl-cert" description:"SSL client certificate file"`
|
||||
SSLKey string `long:"ssl-key" description:"SSL client certificate key file"`
|
||||
OpenTimeout int `long:"open-timeout" description:"Maximum wait time for connection, in seconds" default:"30"`
|
||||
RetryDelay uint `long:"open-retry-delay" description:"Number of seconds to wait before retrying the connection" default:"3"`
|
||||
RetryCount uint `long:"open-retry" description:"Number of times to retry establishing connection" default:"0"`
|
||||
HTTPAddr string `long:"address" description:"anyhttp address. See https://pkg.go.dev/go.balki.me/anyhttp#readme-address-syntax" default:""`
|
||||
HTTPHost string `long:"bind" description:"HTTP server host" default:"localhost"`
|
||||
HTTPPort uint `long:"listen" description:"HTTP server listen port" default:"8081"`
|
||||
AuthUser string `long:"auth-user" description:"HTTP basic auth user"`
|
||||
@@ -42,6 +51,8 @@ type Options struct {
|
||||
LockSession bool `long:"lock-session" description:"Lock session to a single database connection"`
|
||||
Bookmark string `short:"b" long:"bookmark" description:"Bookmark to use for connection. Bookmark files are stored under $HOME/.pgweb/bookmarks/*.toml" default:""`
|
||||
BookmarksDir string `long:"bookmarks-dir" description:"Overrides default directory for bookmark files to search" default:""`
|
||||
BookmarksOnly bool `long:"bookmarks-only" description:"Allow only connections from bookmarks"`
|
||||
QueriesDir string `long:"queries-dir" description:"Overrides default directory for local queries"`
|
||||
DisablePrettyJSON bool `long:"no-pretty-json" description:"Disable JSON formatting feature for result export"`
|
||||
DisableSSH bool `long:"no-ssh" description:"Disable database connections via SSH"`
|
||||
ConnectBackend string `long:"connect-backend" description:"Enable database authentication through a third party backend"`
|
||||
@@ -53,6 +64,9 @@ type Options struct {
|
||||
Cors bool `long:"cors" description:"Enable Cross-Origin Resource Sharing (CORS)"`
|
||||
CorsOrigin string `long:"cors-origin" description:"Allowed CORS origins" default:"*"`
|
||||
BinaryCodec string `long:"binary-codec" description:"Codec for binary data serialization, one of 'none', 'hex', 'base58', 'base64'" default:"none"`
|
||||
MetricsEnabled bool `long:"metrics" description:"Enable Prometheus metrics endpoint"`
|
||||
MetricsPath string `long:"metrics-path" description:"Path prefix for Prometheus metrics endpoint" default:"/metrics"`
|
||||
MetricsAddr string `long:"metrics-addr" description:"Listen host and port for Prometheus metrics server"`
|
||||
}
|
||||
|
||||
var Opts Options
|
||||
@@ -79,6 +93,23 @@ func ParseOptions(args []string) (Options, error) {
|
||||
opts.Prefix = getPrefixedEnvVar("URL_PREFIX")
|
||||
}
|
||||
|
||||
if opts.Passfile == "" {
|
||||
passfile := os.Getenv("PGPASSFILE")
|
||||
if passfile == "" {
|
||||
passfile = filepath.Join(os.Getenv("HOME"), ".pgpass")
|
||||
}
|
||||
|
||||
_, err := os.Stat(passfile)
|
||||
if err == nil {
|
||||
_, err = pgpassfile.ReadPassfile(passfile)
|
||||
if err == nil {
|
||||
opts.Passfile = passfile
|
||||
} else {
|
||||
fmt.Printf("[WARN] Pgpass file unreadable: %s\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle edge case where pgweb is started with a default host `localhost` and no user.
|
||||
// When user is not set the `lib/pq` connection will fail and cause pgweb's termination.
|
||||
if (opts.Host == "localhost" || opts.Host == "127.0.0.1") && opts.User == "" {
|
||||
@@ -89,6 +120,10 @@ func ParseOptions(args []string) (Options, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if getPrefixedEnvVar("BOOKMARKS_ONLY") != "" {
|
||||
opts.BookmarksOnly = true
|
||||
}
|
||||
|
||||
if getPrefixedEnvVar("SESSIONS") != "" {
|
||||
opts.Sessions = true
|
||||
}
|
||||
@@ -108,7 +143,7 @@ func ParseOptions(args []string) (Options, error) {
|
||||
opts.SSLMode = ""
|
||||
}
|
||||
|
||||
if opts.Prefix != "" && !strings.Contains(opts.Prefix, "/") {
|
||||
if opts.Prefix != "" && !strings.HasSuffix(opts.Prefix, "/") {
|
||||
opts.Prefix = opts.Prefix + "/"
|
||||
}
|
||||
|
||||
@@ -133,6 +168,34 @@ func ParseOptions(args []string) (Options, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if opts.BookmarksOnly {
|
||||
if opts.URL != "" {
|
||||
return opts, errors.New("--url not supported in bookmarks-only mode")
|
||||
}
|
||||
if opts.Host != "" && opts.Host != "localhost" {
|
||||
return opts, errors.New("--host not supported in bookmarks-only mode")
|
||||
}
|
||||
if opts.ConnectBackend != "" {
|
||||
return opts, errors.New("--connect-backend not supported in bookmarks-only mode")
|
||||
}
|
||||
}
|
||||
|
||||
homePath, err := homedir.Dir()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[WARN] can't detect home dir: %v", err)
|
||||
homePath = os.Getenv("HOME")
|
||||
}
|
||||
|
||||
if homePath != "" {
|
||||
if opts.BookmarksDir == "" {
|
||||
opts.BookmarksDir = filepath.Join(homePath, ".pgweb/bookmarks")
|
||||
}
|
||||
|
||||
if opts.QueriesDir == "" {
|
||||
opts.QueriesDir = filepath.Join(homePath, ".pgweb/queries")
|
||||
}
|
||||
}
|
||||
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
@@ -175,7 +238,7 @@ func AvailableEnvVars() string {
|
||||
return strings.Join([]string{
|
||||
" " + envVarPrefix + "DATABASE_URL Database connection string",
|
||||
" " + envVarPrefix + "URL_PREFIX HTTP server path prefix",
|
||||
" " + envVarPrefix + "SESSIONS: Enable multiple database sessions",
|
||||
" " + envVarPrefix + "SESSIONS Enable multiple database sessions",
|
||||
" " + envVarPrefix + "LOCK_SESSION Lock session to a single database connection",
|
||||
" " + envVarPrefix + "AUTH_USER HTTP basic auth username",
|
||||
" " + envVarPrefix + "AUTH_PASS HTTP basic auth password",
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mitchellh/go-homedir"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestParseOptions(t *testing.T) {
|
||||
// Test default behavior
|
||||
var hdir string
|
||||
if d, err := homedir.Dir(); err == nil {
|
||||
hdir = d
|
||||
}
|
||||
|
||||
t.Run("defaults", func(t *testing.T) {
|
||||
opts, err := ParseOptions([]string{})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, false, opts.Sessions)
|
||||
@@ -20,28 +28,70 @@ func TestParseOptions(t *testing.T) {
|
||||
assert.Equal(t, 180, opts.ConnectionIdleTimeout)
|
||||
assert.Equal(t, false, opts.Cors)
|
||||
assert.Equal(t, "*", opts.CorsOrigin)
|
||||
assert.Equal(t, "", opts.Passfile)
|
||||
assert.Equal(t, filepath.Join(hdir, ".pgweb/bookmarks"), opts.BookmarksDir)
|
||||
})
|
||||
|
||||
// Test sessions
|
||||
opts, err = ParseOptions([]string{"--sessions", "1"})
|
||||
t.Run("sessions", func(t *testing.T) {
|
||||
opts, err := ParseOptions([]string{"--sessions", "1"})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, true, opts.Sessions)
|
||||
})
|
||||
|
||||
// Test url prefix
|
||||
opts, err = ParseOptions([]string{"--prefix", "pgweb"})
|
||||
t.Run("url prefix", func(t *testing.T) {
|
||||
opts, err := ParseOptions([]string{"--prefix", "pgweb"})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "pgweb/", opts.Prefix)
|
||||
|
||||
opts, err = ParseOptions([]string{"--prefix", "pgweb/"})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "pgweb/", opts.Prefix)
|
||||
})
|
||||
|
||||
// Test connect backend options
|
||||
opts, err = ParseOptions([]string{"--connect-backend", "test"})
|
||||
t.Run("connect backend", func(t *testing.T) {
|
||||
_, err := ParseOptions([]string{"--connect-backend", "test"})
|
||||
assert.EqualError(t, err, "--sessions flag must be set")
|
||||
|
||||
opts, err = ParseOptions([]string{"--connect-backend", "test", "--sessions"})
|
||||
_, err = ParseOptions([]string{"--connect-backend", "test", "--sessions"})
|
||||
assert.EqualError(t, err, "--connect-token flag must be set")
|
||||
|
||||
opts, err = ParseOptions([]string{"--connect-backend", "test", "--sessions", "--connect-token", "token"})
|
||||
_, err = ParseOptions([]string{"--connect-backend", "test", "--sessions", "--connect-token", "token"})
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("passfile", func(t *testing.T) {
|
||||
defer os.Unsetenv("PGPASSFILE")
|
||||
|
||||
// File does not exist
|
||||
os.Setenv("PGPASSFILE", "/tmp/foo")
|
||||
opts, err := ParseOptions([]string{})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "", opts.Passfile)
|
||||
|
||||
// File exists and valid
|
||||
os.Setenv("PGPASSFILE", "../../data/passfile")
|
||||
opts, err = ParseOptions([]string{})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "../../data/passfile", opts.Passfile)
|
||||
|
||||
// Set via flag
|
||||
os.Unsetenv("PGPASSFILE")
|
||||
opts, err = ParseOptions([]string{"--passfile", "../../data/passfile"})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "../../data/passfile", opts.Passfile)
|
||||
})
|
||||
|
||||
t.Run("bookmarks only mode", func(t *testing.T) {
|
||||
_, err := ParseOptions([]string{"--bookmarks-only"})
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = ParseOptions([]string{"--bookmarks-only", "--url", "test"})
|
||||
assert.EqualError(t, err, "--url not supported in bookmarks-only mode")
|
||||
|
||||
_, err = ParseOptions([]string{"--bookmarks-only", "--host", "test", "--port", "5432"})
|
||||
assert.EqualError(t, err, "--host not supported in bookmarks-only mode")
|
||||
|
||||
_, err = ParseOptions([]string{"--bookmarks-only", "--connect-backend", "test", "--sessions", "--connect-token", "token", "--url", "127.0.0.2"})
|
||||
assert.EqualError(t, err, "--connect-backend not supported in bookmarks-only mode")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
const (
|
||||
// Version is the current Pgweb application version
|
||||
Version = "0.12.0"
|
||||
Version = "0.17.0"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package connect
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
errBackendConnectError = errors.New("unable to connect to the auth backend")
|
||||
errConnStringRequired = errors.New("connection string is required")
|
||||
)
|
||||
|
||||
// Request holds the resource request details
|
||||
type Request struct {
|
||||
Resource string `json:"resource"`
|
||||
Token string `json:"token"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
}
|
||||
|
||||
// Credential holds the database connection string
|
||||
type Credential struct {
|
||||
DatabaseURL string `json:"database_url"`
|
||||
}
|
||||
@@ -6,8 +6,11 @@ import (
|
||||
neturl "net/url"
|
||||
"os"
|
||||
"os/user"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgpassfile"
|
||||
|
||||
"github.com/sosedoff/pgweb/pkg/command"
|
||||
)
|
||||
|
||||
@@ -76,6 +79,22 @@ func FormatURL(opts command.Options) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// When password is not provided, look it up from a .pgpass file
|
||||
if uri.User != nil {
|
||||
pass, _ := uri.User.Password()
|
||||
if pass == "" && opts.Passfile != "" {
|
||||
pass = lookupPassword(opts, uri)
|
||||
if pass != "" {
|
||||
uri.User = neturl.UserPassword(uri.User.Username(), pass)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Configure default connect timeout
|
||||
if opts.OpenTimeout > 0 {
|
||||
params["connect_timeout"] = strconv.Itoa(opts.OpenTimeout)
|
||||
}
|
||||
|
||||
// Rebuild query params
|
||||
query := neturl.Values{}
|
||||
for k, v := range params {
|
||||
@@ -125,6 +144,16 @@ func BuildStringFromOptions(opts command.Options) (string, error) {
|
||||
query.Add("sslrootcert", opts.SSLRootCert)
|
||||
}
|
||||
|
||||
// Grab password from .pgpass file if it's available
|
||||
if opts.Pass == "" && opts.Passfile != "" {
|
||||
opts.Pass = lookupPassword(opts, nil)
|
||||
}
|
||||
|
||||
// Configure default connect timeout
|
||||
if opts.OpenTimeout > 0 {
|
||||
query.Add("connect_timeout", strconv.Itoa(opts.OpenTimeout))
|
||||
}
|
||||
|
||||
url := neturl.URL{
|
||||
Scheme: "postgres",
|
||||
Host: fmt.Sprintf("%v:%v", opts.Host, opts.Port),
|
||||
@@ -135,3 +164,34 @@ func BuildStringFromOptions(opts command.Options) (string, error) {
|
||||
|
||||
return url.String(), nil
|
||||
}
|
||||
|
||||
func lookupPassword(opts command.Options, url *neturl.URL) string {
|
||||
if opts.Passfile == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
passfile, err := pgpassfile.ReadPassfile(opts.Passfile)
|
||||
if err != nil {
|
||||
fmt.Println("[WARN] .pgpassfile", opts.Passfile, "is not readable")
|
||||
return ""
|
||||
}
|
||||
|
||||
if url != nil {
|
||||
var dbName string
|
||||
fmt.Sscanf(url.Path, "/%s", &dbName) //nolint
|
||||
|
||||
return passfile.FindPassword(
|
||||
url.Hostname(),
|
||||
url.Port(),
|
||||
dbName,
|
||||
url.User.Username(),
|
||||
)
|
||||
}
|
||||
|
||||
return passfile.FindPassword(
|
||||
opts.Host,
|
||||
fmt.Sprintf("%d", opts.Port),
|
||||
opts.DbName,
|
||||
opts.User,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,192 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_Invalid_Url(t *testing.T) {
|
||||
func TestBuildStringFromOptions(t *testing.T) {
|
||||
t.Run("valid url", func(t *testing.T) {
|
||||
url := "postgres://myhost/database"
|
||||
str, err := BuildStringFromOptions(command.Options{URL: url})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, url, str)
|
||||
})
|
||||
|
||||
t.Run("with sslmode param", func(t *testing.T) {
|
||||
str, err := BuildStringFromOptions(command.Options{
|
||||
URL: "postgres://myhost/database",
|
||||
SSLMode: "disable",
|
||||
})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "postgres://myhost/database?sslmode=disable", str)
|
||||
})
|
||||
|
||||
t.Run("sets sslmode param if not set", func(t *testing.T) {
|
||||
str, err := BuildStringFromOptions(command.Options{
|
||||
URL: "postgres://localhost/database",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "postgres://localhost/database?sslmode=disable", str)
|
||||
|
||||
str, err = BuildStringFromOptions(command.Options{
|
||||
URL: "postgres://127.0.0.1/database",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "postgres://127.0.0.1/database?sslmode=disable", str)
|
||||
})
|
||||
|
||||
t.Run("sslmode as an option", func(t *testing.T) {
|
||||
str, err := BuildStringFromOptions(command.Options{
|
||||
URL: "postgres://localhost/database",
|
||||
SSLMode: "require",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "postgres://localhost/database?sslmode=require", str)
|
||||
|
||||
str, err = BuildStringFromOptions(command.Options{
|
||||
URL: "postgres://127.0.0.1/database",
|
||||
SSLMode: "require",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "postgres://127.0.0.1/database?sslmode=require", str)
|
||||
})
|
||||
|
||||
t.Run("localhost and sslmode flag", func(t *testing.T) {
|
||||
str, err := BuildStringFromOptions(command.Options{
|
||||
URL: "postgres://localhost/database?sslmode=require",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "postgres://localhost/database?sslmode=require", str)
|
||||
|
||||
str, err = BuildStringFromOptions(command.Options{
|
||||
URL: "postgres://127.0.0.1/database?sslmode=require",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "postgres://127.0.0.1/database?sslmode=require", str)
|
||||
})
|
||||
|
||||
t.Run("extended options", func(t *testing.T) {
|
||||
str, err := BuildStringFromOptions(command.Options{
|
||||
URL: "postgres://localhost/database?sslmode=require&sslcert=cert&sslkey=key&sslrootcert=ca",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "postgres://localhost/database?sslcert=cert&sslkey=key&sslmode=require&sslrootcert=ca", str)
|
||||
})
|
||||
|
||||
t.Run("from flags", func(t *testing.T) {
|
||||
str, err := BuildStringFromOptions(command.Options{
|
||||
Host: "host",
|
||||
Port: 5432,
|
||||
User: "user",
|
||||
Pass: "password",
|
||||
DbName: "db",
|
||||
})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "postgres://user:password@host:5432/db", str)
|
||||
})
|
||||
|
||||
t.Run("localhost", func(t *testing.T) {
|
||||
opts := command.Options{
|
||||
Host: "localhost",
|
||||
Port: 5432,
|
||||
User: "user",
|
||||
Pass: "password",
|
||||
DbName: "db",
|
||||
}
|
||||
|
||||
str, err := BuildStringFromOptions(opts)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "postgres://user:password@localhost:5432/db?sslmode=disable", str)
|
||||
|
||||
opts.Host = "127.0.0.1"
|
||||
str, err = BuildStringFromOptions(opts)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "postgres://user:password@127.0.0.1:5432/db?sslmode=disable", str)
|
||||
})
|
||||
|
||||
t.Run("localhost and ssl", func(t *testing.T) {
|
||||
opts := command.Options{
|
||||
Host: "localhost",
|
||||
Port: 5432,
|
||||
User: "user",
|
||||
Pass: "password",
|
||||
DbName: "db",
|
||||
SSLMode: "require",
|
||||
SSLKey: "keyPath",
|
||||
SSLCert: "certPath",
|
||||
SSLRootCert: "caPath",
|
||||
}
|
||||
|
||||
str, err := BuildStringFromOptions(opts)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "postgres://user:password@localhost:5432/db?sslcert=certPath&sslkey=keyPath&sslmode=require&sslrootcert=caPath", str)
|
||||
})
|
||||
|
||||
t.Run("no user", func(t *testing.T) {
|
||||
opts := command.Options{Host: "host", Port: 5432, DbName: "db"}
|
||||
u, _ := user.Current()
|
||||
str, err := BuildStringFromOptions(opts)
|
||||
userAndPass := url.UserPassword(u.Username, "").String()
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, fmt.Sprintf("postgres://%s@host:5432/db", userAndPass), str)
|
||||
})
|
||||
|
||||
t.Run("port", func(t *testing.T) {
|
||||
opts := command.Options{Host: "host", User: "user", Port: 5000, DbName: "db"}
|
||||
str, err := BuildStringFromOptions(opts)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "postgres://user:@host:5000/db", str)
|
||||
})
|
||||
|
||||
t.Run("with pgpass", func(t *testing.T) {
|
||||
opts := command.Options{
|
||||
Host: "localhost",
|
||||
Port: 5432,
|
||||
User: "username",
|
||||
DbName: "dbname",
|
||||
Passfile: "../../data/passfile",
|
||||
}
|
||||
|
||||
str, err := BuildStringFromOptions(opts)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "postgres://username:password@localhost:5432/dbname?sslmode=disable", str)
|
||||
|
||||
opts.User = "foobar"
|
||||
str, err = BuildStringFromOptions(opts)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "postgres://foobar:@localhost:5432/dbname?sslmode=disable", str)
|
||||
|
||||
opts.Host = "127.0.0.1"
|
||||
opts.DbName = "foobar2"
|
||||
str, err = BuildStringFromOptions(opts)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "postgres://foobar:password2@127.0.0.1:5432/foobar2?sslmode=disable", str)
|
||||
})
|
||||
|
||||
t.Run("with connection timeout", func(t *testing.T) {
|
||||
opts := command.Options{
|
||||
URL: "postgres://user:pass@localhost:5432/dbname",
|
||||
OpenTimeout: 30,
|
||||
}
|
||||
str, err := BuildStringFromOptions(opts)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "postgres://user:pass@localhost:5432/dbname?connect_timeout=30&sslmode=disable", str)
|
||||
|
||||
opts = command.Options{
|
||||
Host: "localhost",
|
||||
Port: 5432,
|
||||
User: "username",
|
||||
DbName: "dbname",
|
||||
OpenTimeout: 30,
|
||||
}
|
||||
|
||||
str, err = BuildStringFromOptions(opts)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "postgres://username:@localhost:5432/dbname?connect_timeout=30&sslmode=disable", str)
|
||||
})
|
||||
|
||||
t.Run("invalid url", func(t *testing.T) {
|
||||
opts := command.Options{}
|
||||
examples := []string{
|
||||
"postgre://foobar",
|
||||
@@ -26,147 +211,72 @@ func Test_Invalid_Url(t *testing.T) {
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "Invalid URL. Valid format: postgres://user:password@host:port/db?sslmode=mode", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func Test_Valid_Url(t *testing.T) {
|
||||
url := "postgres://myhost/database"
|
||||
str, err := BuildStringFromOptions(command.Options{URL: url})
|
||||
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, url, str)
|
||||
}
|
||||
|
||||
func Test_Url_And_Ssl_Flag(t *testing.T) {
|
||||
str, err := BuildStringFromOptions(command.Options{
|
||||
URL: "postgres://myhost/database",
|
||||
SSLMode: "disable",
|
||||
})
|
||||
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, "postgres://myhost/database?sslmode=disable", str)
|
||||
}
|
||||
|
||||
func Test_Localhost_Url_And_No_Ssl_Flag(t *testing.T) {
|
||||
str, err := BuildStringFromOptions(command.Options{
|
||||
URL: "postgres://localhost/database",
|
||||
})
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, "postgres://localhost/database?sslmode=disable", str)
|
||||
|
||||
str, err = BuildStringFromOptions(command.Options{
|
||||
URL: "postgres://127.0.0.1/database",
|
||||
})
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, "postgres://127.0.0.1/database?sslmode=disable", str)
|
||||
}
|
||||
|
||||
func Test_Localhost_Url_And_Ssl_Flag(t *testing.T) {
|
||||
str, err := BuildStringFromOptions(command.Options{
|
||||
URL: "postgres://localhost/database",
|
||||
SSLMode: "require",
|
||||
})
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, "postgres://localhost/database?sslmode=require", str)
|
||||
|
||||
str, err = BuildStringFromOptions(command.Options{
|
||||
URL: "postgres://127.0.0.1/database",
|
||||
SSLMode: "require",
|
||||
})
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, "postgres://127.0.0.1/database?sslmode=require", str)
|
||||
}
|
||||
|
||||
func Test_Localhost_Url_And_Ssl_Arg(t *testing.T) {
|
||||
str, err := BuildStringFromOptions(command.Options{
|
||||
URL: "postgres://localhost/database?sslmode=require",
|
||||
})
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, "postgres://localhost/database?sslmode=require", str)
|
||||
|
||||
str, err = BuildStringFromOptions(command.Options{
|
||||
URL: "postgres://127.0.0.1/database?sslmode=require",
|
||||
})
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, "postgres://127.0.0.1/database?sslmode=require", str)
|
||||
}
|
||||
|
||||
func Test_ExtendedSSLFlags(t *testing.T) {
|
||||
str, err := BuildStringFromOptions(command.Options{
|
||||
URL: "postgres://localhost/database?sslmode=require&sslcert=cert&sslkey=key&sslrootcert=ca",
|
||||
})
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, "postgres://localhost/database?sslcert=cert&sslkey=key&sslmode=require&sslrootcert=ca", str)
|
||||
}
|
||||
|
||||
func Test_Flag_Args(t *testing.T) {
|
||||
str, err := BuildStringFromOptions(command.Options{
|
||||
Host: "host",
|
||||
Port: 5432,
|
||||
User: "user",
|
||||
Pass: "password",
|
||||
DbName: "db",
|
||||
})
|
||||
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, "postgres://user:password@host:5432/db", str)
|
||||
}
|
||||
|
||||
func Test_Localhost(t *testing.T) {
|
||||
opts := command.Options{
|
||||
Host: "localhost",
|
||||
Port: 5432,
|
||||
User: "user",
|
||||
Pass: "password",
|
||||
DbName: "db",
|
||||
func TestFormatURL(t *testing.T) {
|
||||
examples := []struct {
|
||||
name string
|
||||
input command.Options
|
||||
result string
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "empty opts",
|
||||
input: command.Options{},
|
||||
},
|
||||
{
|
||||
name: "invalid url",
|
||||
input: command.Options{URL: "barurl"},
|
||||
err: "Invalid URL",
|
||||
},
|
||||
{
|
||||
name: "good",
|
||||
input: command.Options{
|
||||
URL: "postgres://user:pass@localhost:5432/dbname",
|
||||
},
|
||||
result: "postgres://user:pass@localhost:5432/dbname?sslmode=disable",
|
||||
},
|
||||
{
|
||||
name: "password lookup, password set",
|
||||
input: command.Options{
|
||||
URL: "postgres://username:@localhost:5432/dbname",
|
||||
Passfile: "../../data/passfile",
|
||||
},
|
||||
result: "postgres://username:password@localhost:5432/dbname?sslmode=disable",
|
||||
},
|
||||
{
|
||||
name: "password lookup, password not set",
|
||||
input: command.Options{
|
||||
URL: "postgres://username@localhost:5432/dbname",
|
||||
Passfile: "../../data/passfile",
|
||||
},
|
||||
result: "postgres://username:password@localhost:5432/dbname?sslmode=disable",
|
||||
},
|
||||
{
|
||||
name: "with timeout setting",
|
||||
input: command.Options{
|
||||
URL: "postgres://username@localhost:5432/dbname",
|
||||
OpenTimeout: 30,
|
||||
},
|
||||
result: "postgres://username@localhost:5432/dbname?connect_timeout=30&sslmode=disable",
|
||||
},
|
||||
}
|
||||
|
||||
str, err := BuildStringFromOptions(opts)
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, "postgres://user:password@localhost:5432/db?sslmode=disable", str)
|
||||
for _, ex := range examples {
|
||||
t.Run(ex.name, func(t *testing.T) {
|
||||
str, err := FormatURL(ex.input)
|
||||
|
||||
opts.Host = "127.0.0.1"
|
||||
str, err = BuildStringFromOptions(opts)
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, "postgres://user:password@127.0.0.1:5432/db?sslmode=disable", str)
|
||||
}
|
||||
|
||||
func Test_Localhost_And_Ssl(t *testing.T) {
|
||||
opts := command.Options{
|
||||
Host: "localhost",
|
||||
Port: 5432,
|
||||
User: "user",
|
||||
Pass: "password",
|
||||
DbName: "db",
|
||||
SSLMode: "require",
|
||||
SSLKey: "keyPath",
|
||||
SSLCert: "certPath",
|
||||
SSLRootCert: "caPath",
|
||||
if ex.err != "" {
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), ex.err)
|
||||
}
|
||||
assert.Equal(t, ex.result, str)
|
||||
})
|
||||
}
|
||||
|
||||
str, err := BuildStringFromOptions(opts)
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, "postgres://user:password@localhost:5432/db?sslcert=certPath&sslkey=keyPath&sslmode=require&sslrootcert=caPath", str)
|
||||
}
|
||||
|
||||
func Test_No_User(t *testing.T) {
|
||||
opts := command.Options{Host: "host", Port: 5432, DbName: "db"}
|
||||
u, _ := user.Current()
|
||||
str, err := BuildStringFromOptions(opts)
|
||||
userAndPass := url.UserPassword(u.Username, "").String()
|
||||
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, fmt.Sprintf("postgres://%s@host:5432/db", userAndPass), str)
|
||||
}
|
||||
|
||||
func Test_Port(t *testing.T) {
|
||||
opts := command.Options{Host: "host", User: "user", Port: 5000, DbName: "db"}
|
||||
str, err := BuildStringFromOptions(opts)
|
||||
|
||||
assert.Equal(t, nil, err)
|
||||
assert.Equal(t, "postgres://user:@host:5000/db", str)
|
||||
}
|
||||
|
||||
func Test_Blank(t *testing.T) {
|
||||
func TestIsBlank(t *testing.T) {
|
||||
assert.Equal(t, true, IsBlank(command.Options{}))
|
||||
assert.Equal(t, false, IsBlank(command.Options{Host: "host", User: "user"}))
|
||||
assert.Equal(t, false, IsBlank(command.Options{Host: "host", User: "user", DbName: "db"}))
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
startTime time.Time
|
||||
promHandler http.Handler
|
||||
}
|
||||
|
||||
func (h Handler) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||
uptimeGauge.Set(time.Since(h.startTime).Seconds())
|
||||
|
||||
h.promHandler.ServeHTTP(rw, req)
|
||||
}
|
||||
|
||||
func NewHandler() http.Handler {
|
||||
return Handler{
|
||||
startTime: time.Now(),
|
||||
promHandler: promhttp.Handler(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
)
|
||||
|
||||
var (
|
||||
sessionsGauge = promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "pgweb_sessions_count",
|
||||
Help: "Total number of database sessions",
|
||||
})
|
||||
|
||||
queriesCounter = promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "pgweb_queries_count",
|
||||
Help: "Total number of custom queries executed",
|
||||
})
|
||||
|
||||
healthyGauge = promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "pgweb_healthy",
|
||||
Help: "Server health status",
|
||||
})
|
||||
|
||||
startTimeGauge = promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "pgweb_process_start_time",
|
||||
Help: "Server start time, seconds since unix epoch",
|
||||
})
|
||||
|
||||
uptimeGauge = promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "pgweb_uptime",
|
||||
Help: "Server application uptime in seconds",
|
||||
})
|
||||
)
|
||||
|
||||
func init() {
|
||||
startTimeGauge.Set(float64(time.Now().Unix()))
|
||||
}
|
||||
|
||||
func IncrementQueriesCount() {
|
||||
queriesCounter.Inc()
|
||||
}
|
||||
|
||||
func SetSessionsCount(val int) {
|
||||
sessionsGauge.Set(float64(val))
|
||||
}
|
||||
|
||||
func SetHealthy(val bool) {
|
||||
healthy := 0.0
|
||||
if val {
|
||||
healthy = 1.0
|
||||
}
|
||||
healthyGauge.Set(float64(healthy))
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func StartServer(logger *logrus.Logger, path string, addr string) error {
|
||||
logger.WithField("addr", addr).WithField("path", path).Info("starting prometheus metrics server")
|
||||
|
||||
http.Handle(path, NewHandler())
|
||||
return http.ListenAndServe(addr, nil)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type field struct {
|
||||
value string
|
||||
re *regexp.Regexp
|
||||
}
|
||||
|
||||
func (f field) String() string {
|
||||
return f.value
|
||||
}
|
||||
|
||||
func (f field) matches(input string) bool {
|
||||
if f.re != nil {
|
||||
return f.re.MatchString(input)
|
||||
}
|
||||
return f.value == input
|
||||
}
|
||||
|
||||
func newField(value string) (field, error) {
|
||||
f := field{value: value}
|
||||
|
||||
if value == "*" { // match everything
|
||||
f.re = reMatchAll
|
||||
} else if reExpression.MatchString(value) { // match by given expression
|
||||
// Make writing expressions easier for values like "foo_*"
|
||||
if strings.Count(value, "*") == 1 {
|
||||
value = strings.Replace(value, "*", "(.+)", 1)
|
||||
}
|
||||
re, err := regexp.Compile(fmt.Sprintf("^%s$", value))
|
||||
if err != nil {
|
||||
return f, err
|
||||
}
|
||||
f.re = re
|
||||
}
|
||||
|
||||
return f, nil
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_field(t *testing.T) {
|
||||
field, err := newField("val")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "val", field.value)
|
||||
assert.Equal(t, true, field.matches("val"))
|
||||
assert.Equal(t, false, field.matches("value"))
|
||||
|
||||
field, err = newField("*")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "*", field.value)
|
||||
assert.NotNil(t, field.re)
|
||||
assert.Equal(t, true, field.matches("val"))
|
||||
assert.Equal(t, true, field.matches("value"))
|
||||
|
||||
field, err = newField("(.+")
|
||||
assert.EqualError(t, err, "error parsing regexp: missing closing ): `^(.+$`")
|
||||
assert.NotNil(t, field)
|
||||
|
||||
field, err = newField("foo_*")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "foo_*", field.value)
|
||||
assert.NotNil(t, field.re)
|
||||
assert.Equal(t, false, field.matches("foo"))
|
||||
assert.Equal(t, true, field.matches("foo_bar"))
|
||||
assert.Equal(t, true, field.matches("foo_bar_widget"))
|
||||
|
||||
}
|
||||
|
||||
func Test_fieldString(t *testing.T) {
|
||||
field, err := newField("val")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "val", field.String())
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
reMetaPrefix = regexp.MustCompile(`(?m)^\s*--\s*pgweb:\s*(.+)`)
|
||||
reMetaContent = regexp.MustCompile(`([\w]+)\s*=\s*"([^"]+)"`)
|
||||
reMatchAll = regexp.MustCompile(`^(.+)$`)
|
||||
reExpression = regexp.MustCompile(`[\[\]\(\)\+\*]+`)
|
||||
|
||||
allowedKeys = []string{"title", "description", "host", "user", "database", "mode", "timeout"}
|
||||
allowedModes = map[string]bool{"readonly": true, "*": true}
|
||||
)
|
||||
|
||||
type Metadata struct {
|
||||
Title string
|
||||
Description string
|
||||
Host field
|
||||
User field
|
||||
Database field
|
||||
Mode field
|
||||
Timeout *time.Duration
|
||||
}
|
||||
|
||||
func parseMetadata(input string) (*Metadata, error) {
|
||||
fields, err := parseFields(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if fields == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Host must be set to limit queries availability
|
||||
if fields["host"] == "" {
|
||||
return nil, fmt.Errorf("host field must be set")
|
||||
}
|
||||
|
||||
// Allow matching for any user, database and mode by default
|
||||
if fields["user"] == "" {
|
||||
fields["user"] = "*"
|
||||
}
|
||||
if fields["database"] == "" {
|
||||
fields["database"] = "*"
|
||||
}
|
||||
if fields["mode"] == "" {
|
||||
fields["mode"] = "*"
|
||||
}
|
||||
|
||||
hostField, err := newField(fields["host"])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(`error initializing "host" field: %w`, err)
|
||||
}
|
||||
|
||||
userField, err := newField(fields["user"])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(`error initializing "user" field: %w`, err)
|
||||
}
|
||||
|
||||
dbField, err := newField(fields["database"])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(`error initializing "database" field: %w`, err)
|
||||
}
|
||||
|
||||
if !allowedModes[fields["mode"]] {
|
||||
return nil, fmt.Errorf(`invalid "mode" field value: %q`, fields["mode"])
|
||||
}
|
||||
modeField, err := newField(fields["mode"])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(`error initializing "mode" field: %w`, err)
|
||||
}
|
||||
|
||||
var timeout *time.Duration
|
||||
if fields["timeout"] != "" {
|
||||
timeoutSec, err := strconv.Atoi(fields["timeout"])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(`error initializing "timeout" field: %w`, err)
|
||||
}
|
||||
timeoutVal := time.Duration(timeoutSec) * time.Second
|
||||
timeout = &timeoutVal
|
||||
}
|
||||
|
||||
return &Metadata{
|
||||
Title: fields["title"],
|
||||
Description: fields["description"],
|
||||
Host: hostField,
|
||||
User: userField,
|
||||
Database: dbField,
|
||||
Mode: modeField,
|
||||
Timeout: timeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseFields(input string) (map[string]string, error) {
|
||||
result := map[string]string{}
|
||||
seenKeys := map[string]bool{}
|
||||
|
||||
allowed := map[string]bool{}
|
||||
for _, key := range allowedKeys {
|
||||
allowed[key] = true
|
||||
}
|
||||
|
||||
matches := reMetaPrefix.FindAllStringSubmatch(input, -1)
|
||||
if len(matches) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
for _, match := range matches {
|
||||
content := reMetaContent.FindAllStringSubmatch(match[1], -1)
|
||||
if len(content) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, field := range content {
|
||||
key := field[1]
|
||||
value := field[2]
|
||||
|
||||
if !allowed[key] {
|
||||
return result, fmt.Errorf("unknown key: %q", key)
|
||||
}
|
||||
if seenKeys[key] {
|
||||
return result, fmt.Errorf("duplicate key: %q", key)
|
||||
}
|
||||
|
||||
seenKeys[key] = true
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func sanitizeMetadata(input string) string {
|
||||
lines := []string{}
|
||||
for _, line := range strings.Split(input, "\n") {
|
||||
line = reMetaPrefix.ReplaceAllString(line, "")
|
||||
if len(line) > 0 {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_parseFields(t *testing.T) {
|
||||
examples := []struct {
|
||||
input string
|
||||
err error
|
||||
vals map[string]string
|
||||
}{
|
||||
{input: "", err: nil, vals: nil},
|
||||
{input: "foobar", err: nil, vals: nil},
|
||||
{input: "-- no pgweb meta", err: nil, vals: nil},
|
||||
{
|
||||
input: `--pgweb: foo=bar`,
|
||||
err: nil,
|
||||
vals: map[string]string{},
|
||||
},
|
||||
{
|
||||
input: `--pgweb: host="localhost"`,
|
||||
err: nil,
|
||||
vals: map[string]string{"host": "localhost"},
|
||||
},
|
||||
{
|
||||
input: `--pgweb: host="*" user="admin" database ="mydb"; mode = "readonly"`,
|
||||
err: nil,
|
||||
vals: map[string]string{
|
||||
"host": "*",
|
||||
"database": "mydb",
|
||||
"user": "admin",
|
||||
"mode": "readonly",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, ex := range examples {
|
||||
t.Run(ex.input, func(t *testing.T) {
|
||||
fields, err := parseFields(ex.input)
|
||||
assert.Equal(t, ex.err, err)
|
||||
assert.Equal(t, ex.vals, fields)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_parseMetadata(t *testing.T) {
|
||||
examples := []struct {
|
||||
input string
|
||||
err string
|
||||
check func(meta *Metadata) bool
|
||||
}{
|
||||
{
|
||||
input: `--pgweb: `,
|
||||
err: `host field must be set`,
|
||||
},
|
||||
{
|
||||
input: `--pgweb: hello="world"`,
|
||||
err: `unknown key: "hello"`,
|
||||
},
|
||||
{
|
||||
input: `--pgweb: host="localhost" user="anyuser" database="anydb" mode="foo"`,
|
||||
err: `invalid "mode" field value: "foo"`,
|
||||
},
|
||||
{
|
||||
input: "--pgweb2:",
|
||||
check: func(m *Metadata) bool {
|
||||
return m == nil
|
||||
},
|
||||
},
|
||||
{
|
||||
input: `--pgweb: host="localhost"`,
|
||||
check: func(m *Metadata) bool {
|
||||
return m.Host.value == "localhost" &&
|
||||
m.User.value == "*" &&
|
||||
m.Database.value == "*" &&
|
||||
m.Mode.value == "*" &&
|
||||
m.Timeout == nil
|
||||
},
|
||||
},
|
||||
{
|
||||
input: `--pgweb: host="localhost" user="anyuser" database="anydb" mode="*"`,
|
||||
check: func(m *Metadata) bool {
|
||||
return m.Host.value == "localhost" &&
|
||||
m.Host.re == nil &&
|
||||
m.User.value == "anyuser" &&
|
||||
m.Database.value == "anydb" &&
|
||||
m.Mode.value == "*" &&
|
||||
m.Timeout == nil
|
||||
},
|
||||
},
|
||||
{
|
||||
input: `--pgweb: host="localhost" timeout="foo"`,
|
||||
err: `error initializing "timeout" field: strconv.Atoi: parsing "foo": invalid syntax`,
|
||||
},
|
||||
{
|
||||
input: `-- pgweb: host="local(host|dev)"`,
|
||||
check: func(m *Metadata) bool {
|
||||
return m.Host.value == "local(host|dev)" && m.Host.re != nil &&
|
||||
m.Host.matches("localhost") && m.Host.matches("localdev") &&
|
||||
!m.Host.matches("localfoo") && !m.Host.matches("superlocaldev")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, ex := range examples {
|
||||
t.Run(ex.input, func(t *testing.T) {
|
||||
meta, err := parseMetadata(ex.input)
|
||||
if ex.err != "" {
|
||||
assert.Contains(t, err.Error(), ex.err)
|
||||
}
|
||||
if ex.check != nil {
|
||||
assert.Equal(t, true, ex.check(meta))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_sanitizeMetadata(t *testing.T) {
|
||||
examples := []struct {
|
||||
input string
|
||||
output string
|
||||
}{
|
||||
{input: "", output: ""},
|
||||
{input: "foo", output: "foo"},
|
||||
{
|
||||
input: `
|
||||
-- pgweb: metadata
|
||||
query1
|
||||
-- pgweb: more metadata
|
||||
|
||||
query2
|
||||
|
||||
`,
|
||||
output: "query1\nquery2",
|
||||
},
|
||||
}
|
||||
|
||||
for _, ex := range examples {
|
||||
t.Run(ex.input, func(t *testing.T) {
|
||||
assert.Equal(t, ex.output, sanitizeMetadata(ex.input))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package queries
|
||||
|
||||
type Query struct {
|
||||
ID string
|
||||
Path string
|
||||
Meta *Metadata
|
||||
Data string
|
||||
}
|
||||
|
||||
// IsPermitted returns true if a query is allowed to execute for a given db context
|
||||
func (q Query) IsPermitted(host, user, database, mode string) bool {
|
||||
// All fields must be provided for matching
|
||||
if q.Meta == nil || host == "" || user == "" || database == "" || mode == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
meta := q.Meta
|
||||
|
||||
return meta.Host.matches(host) &&
|
||||
meta.User.matches(user) &&
|
||||
meta.Database.matches(database) &&
|
||||
meta.Mode.matches(mode)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestQueryIsPermitted(t *testing.T) {
|
||||
examples := []struct {
|
||||
name string
|
||||
query Query
|
||||
args []string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "no input provided",
|
||||
query: makeQuery("localhost", "someuser", "somedb", "default"),
|
||||
args: makeArgs("", "", "", ""),
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "match on host",
|
||||
query: makeQuery("localhost", "*", "*", "*"),
|
||||
args: makeArgs("localhost", "user", "db", "default"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "match on full set",
|
||||
query: makeQuery("localhost", "user", "database", "mode"),
|
||||
args: makeArgs("localhost", "someuser", "somedb", "default"),
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "match on partial database",
|
||||
query: makeQuery("localhost", "*", "myapp_*", "*"),
|
||||
args: makeArgs("localhost", "user", "myapp_development", "default"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "match on full set but not mode",
|
||||
query: makeQuery("localhost", "*", "*", "readonly"),
|
||||
args: makeArgs("localhost", "user", "db", "default"),
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, ex := range examples {
|
||||
t.Run(ex.name, func(t *testing.T) {
|
||||
result := ex.query.IsPermitted(ex.args[0], ex.args[1], ex.args[2], ex.args[3])
|
||||
assert.Equal(t, ex.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func makeArgs(vals ...string) []string {
|
||||
return vals
|
||||
}
|
||||
|
||||
func makeQuery(host, user, database, mode string) Query {
|
||||
mustfield := func(input string) field {
|
||||
f, err := newField(input)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
return Query{
|
||||
Meta: &Metadata{
|
||||
Host: mustfield(host),
|
||||
User: mustfield(user),
|
||||
Database: mustfield(database),
|
||||
Mode: mustfield(mode),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrQueryDirNotExist = errors.New("queries directory does not exist")
|
||||
ErrQueryFileNotExist = errors.New("query file does not exist")
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
dir string
|
||||
}
|
||||
|
||||
func NewStore(dir string) *Store {
|
||||
return &Store{
|
||||
dir: dir,
|
||||
}
|
||||
}
|
||||
|
||||
func (s Store) Read(id string) (*Query, error) {
|
||||
path := filepath.Join(s.dir, fmt.Sprintf("%s.sql", id))
|
||||
return readQuery(path)
|
||||
}
|
||||
|
||||
func (s Store) ReadAll() ([]Query, error) {
|
||||
entries, err := os.ReadDir(s.dir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
err = ErrQueryDirNotExist
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
queries := []Query{}
|
||||
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if filepath.Ext(name) != ".sql" {
|
||||
continue
|
||||
}
|
||||
|
||||
path := filepath.Join(s.dir, name)
|
||||
query, err := readQuery(path)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[WARN] skipping %q query file due to error: %v\n", name, err)
|
||||
continue
|
||||
}
|
||||
if query == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
queries = append(queries, *query)
|
||||
}
|
||||
|
||||
return queries, nil
|
||||
}
|
||||
|
||||
func readQuery(path string) (*Query, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, ErrQueryFileNotExist
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
dataStr := string(data)
|
||||
|
||||
meta, err := parseMetadata(dataStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if meta == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &Query{
|
||||
ID: strings.Replace(filepath.Base(path), ".sql", "", 1),
|
||||
Path: path,
|
||||
Meta: meta,
|
||||
Data: sanitizeMetadata(dataStr),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//go:build !windows
|
||||
|
||||
package queries
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestStoreReadAll(t *testing.T) {
|
||||
t.Run("valid dir", func(t *testing.T) {
|
||||
queries, err := NewStore("../../data").ReadAll()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 2, len(queries))
|
||||
})
|
||||
|
||||
t.Run("invalid dir", func(t *testing.T) {
|
||||
queries, err := NewStore("../../data2").ReadAll()
|
||||
assert.Equal(t, err.Error(), "queries directory does not exist")
|
||||
assert.Equal(t, 0, len(queries))
|
||||
})
|
||||
}
|
||||
|
||||
func TestStoreRead(t *testing.T) {
|
||||
examples := []struct {
|
||||
id string
|
||||
err string
|
||||
check func(*testing.T, *Query)
|
||||
}{
|
||||
{id: "foo", err: "query file does not exist"},
|
||||
{id: "lc_no_meta"},
|
||||
{id: "lc_invalid_meta", err: `invalid "mode" field value: "foo"`},
|
||||
{
|
||||
id: "lc_example1",
|
||||
check: func(t *testing.T, q *Query) {
|
||||
assert.Equal(t, "lc_example1", q.ID)
|
||||
assert.Equal(t, "../../data/lc_example1.sql", q.Path)
|
||||
assert.Equal(t, "select 'foo'", q.Data)
|
||||
assert.Equal(t, "localhost", q.Meta.Host.String())
|
||||
assert.Equal(t, "*", q.Meta.User.String())
|
||||
assert.Equal(t, "*", q.Meta.Database.String())
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "lc_example2",
|
||||
check: func(t *testing.T, q *Query) {
|
||||
assert.Equal(t, "lc_example2", q.ID)
|
||||
assert.Equal(t, "../../data/lc_example2.sql", q.Path)
|
||||
assert.Equal(t, "-- some comment\nselect 'foo'", q.Data)
|
||||
assert.Equal(t, "localhost", q.Meta.Host.String())
|
||||
assert.Equal(t, "foo", q.Meta.User.String())
|
||||
assert.Equal(t, "*", q.Meta.Database.String())
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
store := NewStore("../../data")
|
||||
|
||||
for _, ex := range examples {
|
||||
t.Run(ex.id, func(t *testing.T) {
|
||||
query, err := store.Read(ex.id)
|
||||
if ex.err != "" || err != nil {
|
||||
assert.Equal(t, ex.err, err.Error())
|
||||
}
|
||||
if ex.check != nil {
|
||||
ex.check(t, query)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
SELECT
|
||||
session_user,
|
||||
current_user,
|
||||
current_database(),
|
||||
current_schemas(false),
|
||||
version()
|
||||
@@ -20,7 +20,7 @@ WITH all_objects AS (
|
||||
pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE
|
||||
c.relkind IN ('r','v','m','S','s','')
|
||||
AND n.nspname !~ '^pg_toast'
|
||||
AND n.nspname !~ '^pg_(toast|temp)'
|
||||
AND n.nspname NOT IN ('information_schema', 'pg_catalog')
|
||||
AND has_schema_privilege(n.nspname, 'USAGE')
|
||||
|
||||
@@ -38,8 +38,8 @@ WITH all_objects AS (
|
||||
JOIN
|
||||
pg_catalog.pg_proc p ON p.pronamespace = n.oid
|
||||
WHERE
|
||||
n.nspname !~ '^pg_toast'
|
||||
n.nspname !~ '^pg_(toast|temp)'
|
||||
AND n.nspname NOT IN ('information_schema', 'pg_catalog')
|
||||
)
|
||||
SELECT * FROM all_objects
|
||||
ORDER BY 1, 2
|
||||
ORDER BY 2, 3
|
||||
|
||||
@@ -2,5 +2,8 @@ SELECT
|
||||
schema_name
|
||||
FROM
|
||||
information_schema.schemata
|
||||
WHERE
|
||||
schema_name NOT IN ('information_schema', 'pg_catalog')
|
||||
AND schema_name !~ '^pg_(toast|temp)'
|
||||
ORDER BY
|
||||
schema_name ASC
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
SELECT * FROM pg_settings
|
||||
@@ -10,5 +10,6 @@ JOIN
|
||||
WHERE
|
||||
n.nspname = $1
|
||||
AND relname = $2
|
||||
AND contype != 'n'
|
||||
ORDER BY
|
||||
contype DESC
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -87,6 +87,13 @@
|
||||
<li><a href="#" id="analyze">Analyze Query</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="load-query-dropdown" class="btn-group left" style="display: none">
|
||||
<button id="load-local-query" type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown" disabled="disabled">
|
||||
Template <span class="caret"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu" role="menu">
|
||||
</ul>
|
||||
</div>
|
||||
<div id="query_progress">Please wait, query is executing...</div>
|
||||
<div class="pull-right">
|
||||
<span id="result-rows-count"></span>
|
||||
@@ -139,6 +146,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="content_modal">
|
||||
<div class="title">
|
||||
Cell Content
|
||||
<div class="actions">
|
||||
<i class="fa fa-times content-modal-action" data-action="close"></i>
|
||||
<i class="fa fa-copy content-modal-action" data-action="copy"></i>
|
||||
</div>
|
||||
</div>
|
||||
<pre class="content"></pre>
|
||||
</div>
|
||||
|
||||
<div id="connection_window">
|
||||
<div class="connection-settings">
|
||||
<div class="header">
|
||||
@@ -171,23 +189,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="connection-standard-group">
|
||||
<div class="connection-bookmarks-group">
|
||||
<div class="form-group bookmarks">
|
||||
<label class="col-sm-3 control-label">Bookmark</label>
|
||||
<div class="col-sm-9">
|
||||
<select class="form-control" id="connection_bookmarks"></select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="connection-standard-group">
|
||||
<div class="form-group">
|
||||
<label class="col-sm-3 control-label">Host</label>
|
||||
<div class="col-sm-7">
|
||||
<div class="col-sm-9">
|
||||
<div class="row">
|
||||
<div class="col-sm-9 col-xs-9">
|
||||
<input type="text" id="pg_host" class="form-control" />
|
||||
</div>
|
||||
<div class="col-sm-2 no-left-padding">
|
||||
<div class="col-sm-3 col-xs-3 no-left-padding">
|
||||
<input type="text" id="pg_port" class="form-control" placeholder="5432" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="col-sm-3 control-label">Username</label>
|
||||
@@ -274,6 +298,7 @@
|
||||
<div id="tables_context_menu">
|
||||
<ul class="dropdown-menu" role="menu">
|
||||
<li><a href="#" data-action="copy">Copy Table Name</a></li>
|
||||
<li><a href="#" data-action="analyze">Analyze Table</a></li>
|
||||
<li class="divider"></li>
|
||||
<li><a href="#" data-action="export" data-format="json">Export to JSON</a></li>
|
||||
<li><a href="#" data-action="export" data-format="csv">Export to CSV</a></li>
|
||||
@@ -299,6 +324,11 @@
|
||||
</div>
|
||||
<div id="current_database_context_menu">
|
||||
<ul class="dropdown-menu" role="menu">
|
||||
<li><a href="#" data-action="show_db_stats">Show Database Stats</a></li>
|
||||
<li><a href="#" data-action="download_db_stats">Download Database Stats</a></li>
|
||||
<li class="divider"></li>
|
||||
<li><a href="#" data-action="server_settings">Show Server Settings</a></li>
|
||||
<li class="divider"></li>
|
||||
<li><a href="#" data-action="export">Export SQL dump</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -312,9 +342,11 @@
|
||||
</div>
|
||||
<div id="results_row_menu">
|
||||
<ul class="dropdown-menu" role="menu">
|
||||
<li><a href="#" data-action="display_value">Display Value</a></li>
|
||||
<li><a href="#" data-action="copy_value">Copy Value</a></li>
|
||||
<li><a href="#" data-action="filter_by_value">Filter Rows By Value</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="error_banner"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+218
-59
@@ -75,23 +75,39 @@ function apiCall(method, path, params, cb) {
|
||||
},
|
||||
success: cb,
|
||||
error: function(xhr, status, data) {
|
||||
if (status == "timeout") {
|
||||
switch(status) {
|
||||
case "error":
|
||||
if (xhr.readyState == 0) { // 0 = UNSENT
|
||||
showErrorBanner("Sorry, something went wrong with your request. Refresh the page and try again!");
|
||||
}
|
||||
break;
|
||||
case "timeout":
|
||||
return cb({ error: "Query timeout after " + timeout + "s" });
|
||||
}
|
||||
|
||||
cb(jQuery.parseJSON(xhr.responseText));
|
||||
var responseText;
|
||||
try {
|
||||
responseText = jQuery.parseJSON(xhr.responseText);
|
||||
}
|
||||
catch {
|
||||
responseText = { error: "Failed to parse the JSON response." };
|
||||
}
|
||||
cb(responseText);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getInfo(cb) { apiCall("get", "/info", {}, cb); }
|
||||
function getConnection(cb) { apiCall("get", "/connection", {}, cb); }
|
||||
function getServerSettings(cb) { apiCall("get", "/server_settings", {}, cb); }
|
||||
function getSchemas(cb) { apiCall("get", "/schemas", {}, cb); }
|
||||
function getObjects(cb) { apiCall("get", "/objects", {}, cb); }
|
||||
function getTables(cb) { apiCall("get", "/tables", {}, cb); }
|
||||
function getTableRows(table, opts, cb) { apiCall("get", "/tables/" + table + "/rows", opts, cb); }
|
||||
function getTableStructure(table, opts, cb) { apiCall("get", "/tables/" + table, opts, cb); }
|
||||
function getTableIndexes(table, cb) { apiCall("get", "/tables/" + table + "/indexes", {}, cb); }
|
||||
function getTableConstraints(table, cb) { apiCall("get", "/tables/" + table + "/constraints", {}, cb); }
|
||||
function getTablesStats(cb) { apiCall("get", "/tables_stats", {}, cb); }
|
||||
function getFunction(id, cb) { apiCall("get", "/functions/" + id, {}, cb); }
|
||||
function getHistory(cb) { apiCall("get", "/history", {}, cb); }
|
||||
function getBookmarks(cb) { apiCall("get", "/bookmarks", {}, cb); }
|
||||
@@ -104,6 +120,18 @@ function encodeQuery(query) {
|
||||
return Base64.encode(query).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ".");
|
||||
}
|
||||
|
||||
function showErrorBanner(text) {
|
||||
if (window.errBannerTimeout != null) {
|
||||
clearTimeout(window.errBannerTimeout);
|
||||
}
|
||||
|
||||
window.errBannerTimeout = setTimeout(function() {
|
||||
$("#error_banner").fadeOut("fast").text("");
|
||||
}, 3000);
|
||||
|
||||
$("#error_banner").text(text).show();
|
||||
}
|
||||
|
||||
function buildSchemaSection(name, objects) {
|
||||
var section = "";
|
||||
|
||||
@@ -158,22 +186,69 @@ function buildSchemaSection(name, objects) {
|
||||
return section;
|
||||
}
|
||||
|
||||
function loadLocalQueries() {
|
||||
if (!appFeatures.local_queries) return;
|
||||
|
||||
$("body").on("click", "a.load-local-query", function(e) {
|
||||
var id = $(this).data("id");
|
||||
|
||||
apiCall("get", "/local_queries/" + id, {}, function(resp) {
|
||||
editor.setValue(resp.query);
|
||||
editor.clearSelection();
|
||||
});
|
||||
});
|
||||
|
||||
apiCall("get", "/local_queries", {}, function(resp) {
|
||||
if (resp.error) return;
|
||||
|
||||
var container = $("#load-query-dropdown").find(".dropdown-menu");
|
||||
|
||||
resp.forEach(function(item) {
|
||||
var title = item.title || item.id;
|
||||
$("<li><a href='#' class='load-local-query' data-id='" + item.id + "'>" + title + "</a></li>").appendTo(container);
|
||||
});
|
||||
|
||||
if (resp.length > 0) $("#load-local-query").prop("disabled", "");
|
||||
$("#load-query-dropdown").show();
|
||||
});
|
||||
}
|
||||
|
||||
function loadSchemas() {
|
||||
$("#objects").html("");
|
||||
|
||||
getObjects(function(data) {
|
||||
if (Object.keys(data).length == 0) {
|
||||
data["public"] = {
|
||||
var emptyObjectList = function() {
|
||||
return {
|
||||
table: [],
|
||||
view: [],
|
||||
materialized_view: [],
|
||||
function: [],
|
||||
sequence: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
for (schema in data) {
|
||||
$(buildSchemaSection(schema, data[schema])).appendTo("#objects");
|
||||
getSchemas(function(schemasData) {
|
||||
if (schemasData.error) {
|
||||
alert("Error while fetching schemas: " + schemasData.error);
|
||||
return;
|
||||
}
|
||||
|
||||
getObjects(function(data) {
|
||||
if (data.error) {
|
||||
alert("Error while fetching database objects: " + data.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.keys(data).length == 0) {
|
||||
data["public"] = emptyObjectList();
|
||||
}
|
||||
|
||||
for (schemaName of schemasData) {
|
||||
// Allow users to see empty schemas if we dont have any objects in them
|
||||
if (!data[schemaName]) {
|
||||
data[schemaName] = emptyObjectList();
|
||||
}
|
||||
|
||||
$(buildSchemaSection(schemaName, data[schemaName])).appendTo("#objects");
|
||||
}
|
||||
|
||||
if (Object.keys(data).length == 1) {
|
||||
@@ -200,6 +275,7 @@ function loadSchemas() {
|
||||
|
||||
bindContextMenus();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
@@ -265,6 +341,12 @@ function performTableAction(table, action, el) {
|
||||
case "copy":
|
||||
copyToClipboard(table.split('.')[1]);
|
||||
break;
|
||||
case "analyze":
|
||||
executeQuery("ANALYZE " + table, function(data) {
|
||||
if (data.error) alert(data.error);
|
||||
resetTable();
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,7 +431,11 @@ function buildTable(results, sortColumn, sortOrder, options) {
|
||||
if (results.rows.length == 0) {
|
||||
$("#results_header").html("");
|
||||
$("#results_body").html("<tr><td>No records found</td></tr>");
|
||||
if (results.stats) {
|
||||
$("#result-rows-count").html(results.stats.query_duration_ms + " ms");
|
||||
} else {
|
||||
$("#result-rows-count").html("");
|
||||
}
|
||||
$("#results").addClass("empty");
|
||||
return;
|
||||
}
|
||||
@@ -359,9 +445,8 @@ function buildTable(results, sortColumn, sortOrder, options) {
|
||||
|
||||
results.columns.forEach(function(col) {
|
||||
if (col === sortColumn) {
|
||||
cols += "<th class='table-header-col active' data-name='" + col + "'" + "data-order=" + sortOrder + ">" + col + " " + sortArrow(sortOrder) + "</th>";
|
||||
}
|
||||
else {
|
||||
cols += "<th class='table-header-col active' data-name='" + col + "' data-order=" + sortOrder + ">" + col + " " + sortArrow(sortOrder) + "</th>";
|
||||
} else {
|
||||
cols += "<th class='table-header-col' data-name='" + col + "'>" + col + "</th>";
|
||||
}
|
||||
});
|
||||
@@ -394,7 +479,11 @@ function buildTable(results, sortColumn, sortOrder, options) {
|
||||
$("#results_body").html(rows);
|
||||
|
||||
// Show number of rows rendered on the page
|
||||
if (results.stats) {
|
||||
$("#result-rows-count").html(results.stats.rows_count + " rows in " + results.stats.query_duration_ms + " ms");
|
||||
} else {
|
||||
$("#result-rows-count").html(results.rows.length + " rows");
|
||||
}
|
||||
}
|
||||
|
||||
function setCurrentTab(id) {
|
||||
@@ -575,6 +664,32 @@ function showPaginatedTableContent() {
|
||||
showTableContent(sortColumn, sortOrder);
|
||||
}
|
||||
|
||||
function showDatabaseStats() {
|
||||
getTablesStats(function(data) {
|
||||
buildTable(data);
|
||||
|
||||
setCurrentTab("table_structure");
|
||||
$("#input").hide();
|
||||
$("#body").prop("class", "full");
|
||||
$("#results").addClass("no-crop");
|
||||
});
|
||||
}
|
||||
|
||||
function downloadDatabaseStats() {
|
||||
openInNewWindow("api/tables_stats", { format: "csv", export: "true" });
|
||||
}
|
||||
|
||||
function showServerSettings() {
|
||||
getServerSettings(function(data) {
|
||||
buildTable(data);
|
||||
|
||||
setCurrentTab("table_content");
|
||||
$("#input").hide();
|
||||
$("#body").prop("class", "full");
|
||||
$("#results").addClass("no-crop");
|
||||
});
|
||||
}
|
||||
|
||||
function showTableStructure() {
|
||||
var name = getCurrentObject().name;
|
||||
|
||||
@@ -682,13 +797,13 @@ function showActivityPanel() {
|
||||
}
|
||||
|
||||
function showQueryProgressMessage() {
|
||||
$("#run, #explain-dropdown-toggle, #csv, #json, #xml").prop("disabled", true);
|
||||
$("#run, #explain-dropdown-toggle, #csv, #json, #xml, #load-local-query").prop("disabled", true);
|
||||
$("#explain-dropdown").removeClass("open");
|
||||
$("#query_progress").show();
|
||||
}
|
||||
|
||||
function hideQueryProgressMessage() {
|
||||
$("#run, #explain-dropdown-toggle, #csv, #json, #xml").prop("disabled", false);
|
||||
$("#run, #explain-dropdown-toggle, #csv, #json, #xml, #load-local-query").prop("disabled", false);
|
||||
$("#query_progress").hide();
|
||||
}
|
||||
|
||||
@@ -1000,18 +1115,19 @@ function getLatestReleaseInfo(current) {
|
||||
function showConnectionSettings() {
|
||||
// Show the current postgres version
|
||||
$(".connection-settings .version").text("v" + appInfo.version).show();
|
||||
$("#connection_window").show();
|
||||
initConnectionWindow();
|
||||
|
||||
// Check github release page for updates
|
||||
getLatestReleaseInfo(appInfo);
|
||||
|
||||
getBookmarks(function(data) {
|
||||
// Do not add any bookmarks if we've got an error
|
||||
if (data.error) {
|
||||
console.log("Cant get bookmarks:", data.error);
|
||||
console.log("Error while fetching bookmarks:", data.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.keys(data).length > 0) {
|
||||
if (data.length > 0) {
|
||||
// Set bookmarks in global var
|
||||
bookmarks = data;
|
||||
|
||||
@@ -1019,21 +1135,40 @@ function showConnectionSettings() {
|
||||
$("#connection_bookmarks").html("");
|
||||
|
||||
// Add blank option
|
||||
$("<option value=''></option>").appendTo("#connection_bookmarks");
|
||||
$("<option value=''>Select a bookmarked database to connect to</option>").appendTo("#connection_bookmarks");
|
||||
|
||||
// Add all available bookmarks
|
||||
for (key in data) {
|
||||
for (key of data) {
|
||||
$("<option value='" + key + "''>" + key + "</option>").appendTo("#connection_bookmarks");
|
||||
}
|
||||
|
||||
$(".bookmarks").show();
|
||||
}
|
||||
else {
|
||||
if (appFeatures.bookmarks_only) {
|
||||
$("#connection_error").html("Running in <b>bookmarks-only</b> mode but <b>NO</b> bookmarks configured.").show();
|
||||
$(".open-connection").hide();
|
||||
} else {
|
||||
$(".bookmarks").hide();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$("#connection_window").show();
|
||||
function initConnectionWindow() {
|
||||
if (appFeatures.bookmarks_only) {
|
||||
$(".connection-group-switch").hide();
|
||||
$(".connection-scheme-group").hide();
|
||||
$(".connection-bookmarks-group").show();
|
||||
$(".connection-standard-group").hide();
|
||||
$(".connection-ssh-group").hide();
|
||||
} else {
|
||||
$(".connection-group-switch").show();
|
||||
$(".connection-scheme-group").hide();
|
||||
$(".connection-bookmarks-group").show();
|
||||
$(".connection-standard-group").show();
|
||||
$(".connection-ssh-group").hide();
|
||||
}
|
||||
}
|
||||
|
||||
function getConnectionString() {
|
||||
@@ -1122,6 +1257,11 @@ function bindTableHeaderMenu() {
|
||||
var menuItem = $(e.target);
|
||||
|
||||
switch(menuItem.data("action")) {
|
||||
case "display_value":
|
||||
var value = $(context).text();
|
||||
$("#content_modal .content").text(value);
|
||||
$("#content_modal").show();
|
||||
break;
|
||||
case "copy_value":
|
||||
copyToClipboard($(context).text());
|
||||
break;
|
||||
@@ -1146,6 +1286,15 @@ function bindCurrentDatabaseMenu() {
|
||||
var menuItem = $(e.target);
|
||||
|
||||
switch(menuItem.data("action")) {
|
||||
case "show_db_stats":
|
||||
showDatabaseStats();
|
||||
break;
|
||||
case "download_db_stats":
|
||||
downloadDatabaseStats();
|
||||
break;
|
||||
case "server_settings":
|
||||
showServerSettings();
|
||||
break;
|
||||
case "export":
|
||||
openInNewWindow("api/export");
|
||||
break;
|
||||
@@ -1351,8 +1500,39 @@ function onInputResize(event) {
|
||||
resizeInput(computedHeight);
|
||||
}
|
||||
|
||||
function bindContentModalEvents() {
|
||||
var contentModal = document.getElementById("content_modal");
|
||||
|
||||
$(window).on("click", function(e) {
|
||||
// Automatically hide the modal on any click outside of the modal window
|
||||
if (e.target && !contentModal.contains(e.target)) {
|
||||
$("#content_modal").hide();
|
||||
}
|
||||
});
|
||||
|
||||
$("#content_modal .content-modal-action").on("click", function() {
|
||||
switch ($(this).data("action")) {
|
||||
case "copy":
|
||||
copyToClipboard($("#content_modal pre").text());
|
||||
break;
|
||||
case "close":
|
||||
$("#content_modal").hide();
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
$("#results").on("dblclick", "td > div", function() {
|
||||
var value = unescapeHtml($(this).html());
|
||||
if (!value) return;
|
||||
|
||||
$("#content_modal pre").html(value);
|
||||
$("#content_modal").show();
|
||||
})
|
||||
}
|
||||
|
||||
$(document).ready(function() {
|
||||
bindInputResizeEvents();
|
||||
bindContentModalEvents();
|
||||
|
||||
$("#table_content").on("click", function() { showTableContent(); });
|
||||
$("#table_structure").on("click", function() { showTableStructure(); });
|
||||
@@ -1624,55 +1804,31 @@ $(document).ready(function() {
|
||||
});
|
||||
|
||||
$("#connection_bookmarks").on("change", function(e) {
|
||||
var name = $.trim($(this).val());
|
||||
if (name == "") return;
|
||||
var selection = $(this).val();
|
||||
|
||||
var item = bookmarks[name];
|
||||
var inputs = [
|
||||
$("#connection_form input[type='text']"),
|
||||
$("#connection_form input[type='password']"),
|
||||
$("#connection_ssl")
|
||||
];
|
||||
|
||||
// Check if bookmark only has url set
|
||||
if (item.url && item.url != "") {
|
||||
$("#connection_url").val(item.url);
|
||||
$("#connection_scheme").click();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fill in bookmarked connection settings
|
||||
$("#pg_host").val(item.host);
|
||||
$("#pg_port").val(item.port);
|
||||
$("#pg_user").val(item.user);
|
||||
$("#pg_password").val(item.password);
|
||||
$("#pg_db").val(item.database);
|
||||
$("#connection_ssl").val(item.ssl);
|
||||
|
||||
if (item.ssh && Object.keys(item.ssh).length > 0) {
|
||||
$("#ssh_host").val(item.ssh.host);
|
||||
$("#ssh_port").val(item.ssh.port);
|
||||
$("#ssh_user").val(item.ssh.user);
|
||||
$("#ssh_password").val(item.ssh.password);
|
||||
$("#ssh_key").val(item.ssh.key);
|
||||
$("#ssh_key_password").val(item.ssh.keypassword);
|
||||
$("#connection_ssh").click();
|
||||
}
|
||||
else {
|
||||
$("#ssh_host").val("");
|
||||
$("#ssh_port").val("");
|
||||
$("#ssh_user").val("");
|
||||
$("#ssh_password").val("");
|
||||
$("#ssh_key").val("");
|
||||
$("#ssh_key_password").val("");
|
||||
$(".connection-ssh-group").hide();
|
||||
$("#connection_standard").click();
|
||||
}
|
||||
inputs.forEach(function(selector) {
|
||||
selector.val("").prop("disabled", selection == "" ? "" : "disabled");
|
||||
});
|
||||
});
|
||||
|
||||
$("#connection_form").on("submit", function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
var button = $(this).find("button.open-connection");
|
||||
var params = {
|
||||
url: getConnectionString()
|
||||
};
|
||||
var params = {};
|
||||
var bookmarkID = $.trim($("#connection_bookmarks").val());
|
||||
|
||||
if (bookmarkID != "") {
|
||||
params["bookmark_id"] = $("#connection_bookmarks").val();
|
||||
}
|
||||
else {
|
||||
params.url = getConnectionString();
|
||||
if (params.url.length == 0) {
|
||||
return;
|
||||
}
|
||||
@@ -1686,6 +1842,7 @@ $(document).ready(function() {
|
||||
params["ssh_key"] = $("#ssh_key").val();
|
||||
params["ssh_key_password"] = $("#ssh_key_password").val()
|
||||
}
|
||||
}
|
||||
|
||||
$("#connection_error").hide();
|
||||
button.prop("disabled", true).text("Please wait...");
|
||||
@@ -1700,6 +1857,7 @@ $(document).ready(function() {
|
||||
else {
|
||||
connected = true;
|
||||
loadSchemas();
|
||||
loadLocalQueries();
|
||||
|
||||
$("#connection_window").hide();
|
||||
$("#current_database").text(resp.current_database);
|
||||
@@ -1740,6 +1898,7 @@ $(document).ready(function() {
|
||||
|
||||
connected = true;
|
||||
loadSchemas();
|
||||
loadLocalQueries();
|
||||
|
||||
$("#current_database").text(resp.current_database);
|
||||
$("#main").show();
|
||||
|
||||
Reference in New Issue
Block a user