add more CLI options and API scaffolding
This commit is contained in:
parent
d244cbf589
commit
24c7720831
|
@ -1,5 +1,12 @@
|
|||
/goserv
|
||||
*.claims.json
|
||||
*.jwk.json
|
||||
*.jws.json
|
||||
*.jwt.txt
|
||||
|
||||
xversion.go
|
||||
|
||||
*_string.go
|
||||
*_vfsdata.go
|
||||
*.env
|
||||
.env
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
# This is an example goreleaser.yaml file with some sane defaults.
|
||||
# Make sure to check the documentation at http://goreleaser.com
|
||||
before:
|
||||
hooks:
|
||||
# You may remove this if you don't use go modules.
|
||||
- go mod download
|
||||
# you may remove this if you don't need go generate
|
||||
- go generate ./...
|
||||
builds:
|
||||
- env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- linux
|
||||
- windows
|
||||
- darwin
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
- arm
|
||||
archives:
|
||||
- replacements:
|
||||
darwin: Darwin
|
||||
linux: Linux
|
||||
windows: Windows
|
||||
386: i386
|
||||
amd64: x86_64
|
||||
arm64: aarch64
|
||||
checksum:
|
||||
name_template: 'checksums.txt'
|
||||
snapshot:
|
||||
name_template: "{{ .Tag }}-next"
|
||||
changelog:
|
||||
sort: asc
|
||||
filters:
|
||||
exclude:
|
||||
- '^docs:'
|
||||
- '^test:'
|
205
README.md
205
README.md
|
@ -1,38 +1,207 @@
|
|||
# goserv
|
||||
# [GoServ](https://git.coolaj86.com/coolaj86/goserv)
|
||||
|
||||
!["golang gopher with goggles"](https://golang.org/lib/godoc/images/footer-gopher.jpg)
|
||||
|
||||
> Boilerplate for how I like to write a backend web service
|
||||
|
||||
## Build
|
||||
|
||||
#### Goreleaser
|
||||
|
||||
See <https://webinstall.dev/goreleaser>
|
||||
|
||||
Local-only
|
||||
|
||||
```bash
|
||||
goreleaser --snapshot --skip-publish --rm-dist
|
||||
```
|
||||
|
||||
Publish
|
||||
|
||||
```bash
|
||||
# Get a token at https://github.com/settings/tokens
|
||||
export GITHUB_TOKEN=xxxxxxx
|
||||
|
||||
# Remove --snapshot to error on non-clean releases
|
||||
goreleaser --snapshot --rm-dist
|
||||
```
|
||||
|
||||
The platform, publish URL, and token file can be changed in `.goreleaser.yml`:
|
||||
|
||||
```yml
|
||||
env_files:
|
||||
gitea_token: ~/.config/goreleaser/gitea_token
|
||||
gitea_urls:
|
||||
api: https://try.gitea.io/api/v1/
|
||||
```
|
||||
|
||||
#### Manually
|
||||
|
||||
```bash
|
||||
git clone ssh://gitea@git.coolaj86.com:22042/coolaj86/goserv.git ./goserv
|
||||
pushd ./goserv
|
||||
bash ./examples/build.sh
|
||||
```
|
||||
|
||||
```bash
|
||||
export GOFLAGS="-mod=vendor"
|
||||
go mod tidy
|
||||
go mod vendor
|
||||
go generate -mod=vendor ./...
|
||||
go build -mod=vendor .
|
||||
go build -mod=vendor -o dist/goserv .
|
||||
```
|
||||
|
||||
To build for another platform (such as Raspberry Pi) set `GOOS` and GOARCH`:
|
||||
|
||||
```bash
|
||||
./goserv run --listen :3000 --serve-path ./overrides
|
||||
GOOS=linux GOARCH=arm64 go build -mod=vendor -o dist/goserv-linux-arm64 .
|
||||
```
|
||||
|
||||
## Eamples and Config Templates
|
||||
#### Run
|
||||
|
||||
```bash
|
||||
./dist/goserv run --listen :3000 --trust-proxy --serve-path ./overrides
|
||||
```
|
||||
|
||||
## Examples and Config Templates
|
||||
|
||||
The example files are located in `./examples`
|
||||
|
||||
- Caddyfile (web server config)
|
||||
- .env (environment variables)
|
||||
- build.sh
|
||||
- Caddyfile (web server config)
|
||||
- .env (environment variables)
|
||||
- build.sh
|
||||
|
||||
```bash
|
||||
export BASE_URL=https://example.com
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
You can use an OIDC provider or sign your own tokens.
|
||||
|
||||
```bash
|
||||
go install -mod=vendor git.rootprojects.org/root/keypairs/cmd/keypairs
|
||||
|
||||
# Generate a keypair
|
||||
keypairs gen -o key.jwk.json --pub pub.jwk.json
|
||||
```
|
||||
|
||||
Create an Admin token:
|
||||
|
||||
```bash
|
||||
# Sign an Admin token
|
||||
echo '{ "sub": "random_ppid", "email": "me@example.com", "iss": "'"${BASE_URL}"'" }' \
|
||||
> admin.claims.json
|
||||
keypairs sign --exp 1h ./key.jwk.json ./admin.claims.json > admin.jwt.txt 2> admin.jws.json
|
||||
|
||||
# verify the Admin token
|
||||
keypairs verify ./pub.jwk.json ./admin.jwt.txt
|
||||
```
|
||||
|
||||
Create a User token:
|
||||
|
||||
```bash
|
||||
# Sign a User token
|
||||
echo '{ "sub": "random_ppid", "email": "me@example.com", "iss": "'"${BASE_URL}"'" }' \
|
||||
> user.claims.json
|
||||
keypairs sign --exp 1h ./key.jwk.json ./user.claims.json > user.jwt.txt 2> user.jws.json
|
||||
|
||||
# verify the User token
|
||||
keypairs verify ./pub.jwk.json ./user.jwt.txt
|
||||
```
|
||||
|
||||
**Impersonation** can be accomplished by appending the token `sub` (aka PPID) to the url as
|
||||
`?user_id=`.
|
||||
|
||||
## REST API
|
||||
|
||||
All routes require authentication, except for those at `/api/public`.
|
||||
|
||||
```txt
|
||||
Authentication: Bearer <token>
|
||||
```
|
||||
|
||||
Here's the API, in brief:
|
||||
|
||||
```txt
|
||||
# Demo Mode Only
|
||||
DELETE /public/reset Drop database and re-initialize
|
||||
|
||||
# Public
|
||||
GET /public/ping Health Check
|
||||
POST /public/setup <= (none) Bootstrap
|
||||
|
||||
# Admin-only
|
||||
GET /admin/ping (authenticated) Health Check
|
||||
|
||||
# User
|
||||
GET /user/ping (authenticated) Health Check
|
||||
```
|
||||
|
||||
When you `GET` anything, it will be wrapped in the `result`.
|
||||
|
||||
#### Bootstrapping
|
||||
|
||||
The first user to hit the `/api/setup` endpoint will be the **admin**:
|
||||
|
||||
```bash
|
||||
export TOKEN=$(cat admin.jwt.txt)
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -X POST "${BASE_URL}/api/public/setup" -H "Authorization: Bearer ${TOKEN}"
|
||||
```
|
||||
|
||||
Then the setup endpoint will be disabled:
|
||||
|
||||
```bash
|
||||
curl -X POST "${BASE_URL}/api/public/setup" -H "Authorization: Bearer ${TOKEN}"
|
||||
```
|
||||
|
||||
## GoDoc
|
||||
|
||||
If the documentation is not public hosted you can view it with GoDoc:
|
||||
|
||||
```bash
|
||||
godoc --http :6060
|
||||
```
|
||||
|
||||
- <http://localhost:6060/pkg/git.example.com/example/goserv/>
|
||||
- <http://localhost:6060/pkg/git.example.com/example/goserv/internal/api>
|
||||
- <http://localhost:6060/pkg/git.example.com/example/goserv/internal/db>
|
||||
|
||||
You can see abbreviated documentation with `go`'s built-in `doc`, for example:
|
||||
|
||||
```bash
|
||||
go doc git.example.com/example/goserv/internal/api
|
||||
```
|
||||
|
||||
<http://localhost:6060>
|
||||
|
||||
## Test
|
||||
|
||||
Create a test database. It must have `test` in the name.
|
||||
|
||||
```sql
|
||||
DROP DATABASE "goserv_test"; CREATE DATABASE "goserv_test";
|
||||
```
|
||||
|
||||
Run the tests:
|
||||
|
||||
```bash
|
||||
export TEST_DATABASE_URL='postgres://postgres:postgres@localhost:5432/goserv_test'
|
||||
go test -mod=vendor ./...
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
This setup can be run on a VPS, such as Digital Ocean, OVH, or Scaleway
|
||||
for \$5/month with minimal dependencies:
|
||||
|
||||
- VPS
|
||||
- Caddy
|
||||
- PostgreSQL
|
||||
- Serviceman
|
||||
- VPS
|
||||
- Caddy
|
||||
- PostgreSQL
|
||||
- Serviceman
|
||||
|
||||
**Mac**, **Linux**:
|
||||
|
||||
|
@ -80,7 +249,7 @@ sudo env PATH="$PATH" \
|
|||
caddy run --config ./Caddyfile
|
||||
```
|
||||
|
||||
See the Cheat Sheet at https://webinstall.dev/caddy
|
||||
See the Cheat Sheets at https://webinstall.dev/caddy
|
||||
and https://webinstall.dev/serviceman
|
||||
|
||||
### PostgreSQL (Database)
|
||||
|
@ -106,6 +275,14 @@ psql 'postgres://postgres:postgres@localhost:5432/postgres'
|
|||
See the Cheat Sheets at https://webinstall.dev/postgres
|
||||
and https://webinstall.dev/serviceman
|
||||
|
||||
## License
|
||||
## Licenses
|
||||
|
||||
Copyright 2020. All rights reserved.
|
||||
Copyright 2020 The GoServ Authors. All rights reserved.
|
||||
|
||||
### Exceptions
|
||||
|
||||
- `countries.json` LGPL, taken from <https://github.com/stefangabos/world_countries>
|
||||
- `flags.json` MIT, taken from <https://github.com/matiassingers/emoji-flags>
|
||||
|
||||
These are probably also in the Public Domain. \
|
||||
(gathering the official data from any source would yield the same dataset)
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
// +build !dev
|
||||
|
||||
//go:generate go run -mod vendor github.com/shurcooL/vfsgen/cmd/vfsgendev -source="git.coolaj86.com/coolaj86/goserv/assets".Assets
|
||||
//go:generate go run -mod vendor github.com/shurcooL/vfsgen/cmd/vfsgendev -source="git.example.com/example/goserv/assets".Assets
|
||||
|
||||
package assets
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
// +build !dev
|
||||
|
||||
//go:generate go run -mod vendor github.com/shurcooL/vfsgen/cmd/vfsgendev -source="git.coolaj86.com/coolaj86/goserv/assets/configfs".Assets
|
||||
//go:generate go run -mod vendor github.com/shurcooL/vfsgen/cmd/vfsgendev -source="git.example.com/example/goserv/assets/configfs".Assets
|
||||
|
||||
package configfs
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
-- this is only used for the tests
|
||||
DROP TABLE IF EXISTS "authn";
|
||||
DROP TABLE IF EXISTS "events";
|
|
@ -8,12 +8,12 @@ CREATE TABLE IF NOT EXISTS "authn" (
|
|||
"id" TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"ppid" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"verified" BOOL NOT NULL DEFAULT FALSE,
|
||||
"created_at" TIMESTAMP NOT NULL DEFAULT (now() AT TIME ZONE 'UTC'),
|
||||
"updated_at" TIMESTAMP NOT NULL DEFAULT (now() AT TIME ZONE 'UTC'),
|
||||
"deleted_at" TIMESTAMP NOT NULL DEFAULT ('epoch' AT TIME ZONE 'UTC')
|
||||
"verified_at" TIMESTAMPTZ NOT NULL DEFAULT ('0001-01-01 00:00:00' AT TIME ZONE 'UTC'),
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT (now() AT TIME ZONE 'UTC'),
|
||||
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT (now() AT TIME ZONE 'UTC'),
|
||||
"deleted_at" TIMESTAMPTZ NOT NULL DEFAULT ('0001-01-01 00:00:00' AT TIME ZONE 'UTC')
|
||||
);
|
||||
--CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_slug" ON "authn" ("ppid");
|
||||
--CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_ppid" ON "authn" ("ppid");
|
||||
CREATE INDEX IF NOT EXISTS "idx_ppid" ON "authn" ("ppid");
|
||||
CREATE INDEX IF NOT EXISTS "idx_email" ON "authn" ("email");
|
||||
|
||||
|
@ -26,8 +26,8 @@ CREATE TABLE IF NOT EXISTS "events" (
|
|||
"table" TEXT NOT NULL,
|
||||
"record" TEXT NOT NULL,
|
||||
"by" TEXT NOT NULL,
|
||||
"at" TIMESTAMP NOT NULL DEFAULT (now() AT TIME ZONE 'UTC'),
|
||||
"at" TIMESTAMP NOT NULL DEFAULT (now() AT TIME ZONE 'UTC')
|
||||
);
|
||||
--CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_record" ON "events" ("record");
|
||||
CREATE INDEX IF NOT EXISTS "idx_record" ON authn ("record");
|
||||
CREATE INDEX IF NOT EXISTS "idx_by" ON authn ("by");
|
||||
CREATE INDEX IF NOT EXISTS "idx_record" ON "events" ("record");
|
||||
CREATE INDEX IF NOT EXISTS "idx_by" ON "events" ("by");
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
// See also
|
||||
//
|
||||
// internal/api: http://localhost:6060/pkg/git.example.com/example/project/internal/api
|
||||
//
|
||||
// internal/db: http://localhost:6060/pkg/git.example.com/example/project/internal/db
|
||||
package main
|
|
@ -15,6 +15,8 @@ example.com {
|
|||
|
||||
# reverse proxy /api to :3000
|
||||
reverse_proxy /api/* localhost:3000
|
||||
reverse_proxy /.well-known/openid-configuration localhost:3000
|
||||
reverse_proxy /.well-known/jwks.json localhost:3000
|
||||
|
||||
# serve static files from public folder, but not /api
|
||||
@notApi {
|
||||
|
@ -22,6 +24,8 @@ example.com {
|
|||
try_files {path} {path}/ {path}/index.html
|
||||
}
|
||||
not path /api/*
|
||||
not path /.well-known/openid-configuration
|
||||
not path /.well-known/jwks.json
|
||||
}
|
||||
route {
|
||||
rewrite @notApi {http.matchers.file.relative}
|
||||
|
|
|
@ -8,4 +8,3 @@ go mod tidy
|
|||
go mod vendor
|
||||
go generate -mod=vendor ./...
|
||||
go build -mod=vendor .
|
||||
|
||||
|
|
|
@ -1,2 +1,22 @@
|
|||
PORT="3000"
|
||||
#LISTEN=":3000"
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres
|
||||
TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres_test
|
||||
|
||||
TRUST_PROXY=false
|
||||
|
||||
# Supports OIDC-compliant SSO issuers / providers
|
||||
# (Auth0, Google, etc)
|
||||
# (should provide .well-known/openid-configuration and .well-known/jwks.json)
|
||||
OIDC_WHITELIST=https://mock.pocketid.app
|
||||
|
||||
# Public Key may be provided in addition to or in lieu of OIDC_WHITELIST
|
||||
# can be RSA or ECDSA, either a filename or JWK/JSON (or PEM, but good luck escaping the newlines)
|
||||
#
|
||||
# go install -mod=vendor git.rootprojects.org/root/keypairs/cmd/keypairs
|
||||
# keypairs gen -o priv.jwk.json --pub pub.jwk.json
|
||||
#
|
||||
#PUBLIC_KEY='{"crv":"P-256","kid":"kN4qj1w01Ry6ElG9I3qAVJOZFYLDklPFUdHaKozWtmc","kty":"EC","use":"sig","x":"SzzNgrOM_N0GwQWZPGFcdIKmfoQD6aXIzYm4gzGyPgQ","y":"erYeb884pk0BGMewDzEh_qYDB0aOFIxFjrXdqIzkmbw"}'
|
||||
PUBLIC_KEY=./pub.jwk.json
|
||||
|
||||
#--demo is explicit
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
# Install `keypairs`
|
||||
go install -mod=vendor git.rootprojects.org/root/keypairs/cmd/keypairs
|
||||
|
||||
# Generate a keypair
|
||||
keypairs gen -o key.jwk.json --pub pub.jwk.json
|
|
@ -0,0 +1,4 @@
|
|||
#!/bin/bash
|
||||
bash ./examples/build.sh
|
||||
bash ./examples/genkeys.sh
|
||||
bash ./examples/test.sh
|
|
@ -0,0 +1,45 @@
|
|||
#!/bin/bash
|
||||
|
||||
export BASE_URL=http://localhost:7070
|
||||
#export BASE_URL=https://example.com
|
||||
#CURL_OPTS="-sS"
|
||||
CURL_OPTS=""
|
||||
|
||||
mkdir -p ./tmp/
|
||||
|
||||
# Sign an Admin token
|
||||
echo '{ "sub": "admin_ppid", "email": "me@example.com", "iss": "'"${BASE_URL}"'" }' > ./tmp/admin.claims.json
|
||||
keypairs sign --exp 1h ./key.jwk.json ./tmp/admin.claims.json > ./tmp/admin.jwt.txt 2> ./tmp/admin.jws.json
|
||||
export ADMIN_TOKEN=$(cat ./tmp/admin.jwt.txt)
|
||||
|
||||
# verify the Admin token
|
||||
#keypairs verify ./pub.jwk.json ./admin.jwt.txt
|
||||
|
||||
|
||||
# Sign a User token
|
||||
echo '{ "sub": "random_ppid", "email": "me@example.com", "iss": "'"${BASE_URL}"'" }' > ./tmp/user.claims.json
|
||||
keypairs sign --exp 1h ./key.jwk.json ./tmp/user.claims.json > ./tmp/user.jwt.txt 2> ./tmp/user.jws.json
|
||||
export USER_TOKEN=$(cat ./tmp/user.jwt.txt)
|
||||
|
||||
# verify the User token
|
||||
#keypairs verify ./pub.jwk.json ./user.jwt.txt
|
||||
|
||||
|
||||
EID=$(cat ./user.jws.json | grep sub | cut -d'"' -f 4)
|
||||
|
||||
echo ""
|
||||
echo 'DELETE /api/public/reset (only works in --demo mode, deletes all data)'
|
||||
curl $CURL_OPTS -X DELETE "${BASE_URL}/api/public/reset"
|
||||
echo ""
|
||||
|
||||
echo ""
|
||||
echo "Bootstrap with a new admin (only works once)"
|
||||
curl -f $CURL_OPTS -X POST "${BASE_URL}/api/public/setup" \
|
||||
-H "Authorization: Bearer ${ADMIN_TOKEN}"
|
||||
echo ""
|
||||
|
||||
echo "Create a new user"
|
||||
curl $CURL_OPTS -X POST "${BASE_URL}/api/users" \
|
||||
-H "Authorization: Bearer ${USER_TOKEN}" \
|
||||
-d '{ "display_name": "Jo Doe" }'
|
||||
echo ""
|
7
go.mod
7
go.mod
|
@ -1,14 +1,17 @@
|
|||
module git.coolaj86.com/coolaj86/goserv
|
||||
module git.example.com/example/goserv
|
||||
|
||||
go 1.15
|
||||
|
||||
require (
|
||||
git.rootprojects.org/root/go-gitver v1.1.3 // indirect
|
||||
git.rootprojects.org/root/go-gitver/v2 v2.0.1
|
||||
git.rootprojects.org/root/keypairs v0.6.3
|
||||
github.com/go-chi/chi v4.1.2+incompatible
|
||||
github.com/jmoiron/sqlx v1.2.0
|
||||
github.com/joho/godotenv v1.3.0
|
||||
github.com/lib/pq v1.8.0
|
||||
github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749 // indirect
|
||||
github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546
|
||||
golang.org/x/tools v0.0.0-20200925191224-5d1fdd8fa346 // indirect
|
||||
golang.org/x/tools v0.0.0-20201001230009-b5b87423c93b
|
||||
google.golang.org/appengine v1.6.6 // indirect
|
||||
)
|
||||
|
|
12
go.sum
12
go.sum
|
@ -1,3 +1,9 @@
|
|||
git.rootprojects.org/root/go-gitver v1.1.3 h1:/qR9z53vY+IFhWRxLkF9cjaiWh8xRJIm6gyuW+MG81A=
|
||||
git.rootprojects.org/root/go-gitver v1.1.3/go.mod h1:Rj1v3TBhvdaSphFEqMynUYwAz/4f+wY/+syBTvRrmlI=
|
||||
git.rootprojects.org/root/go-gitver/v2 v2.0.1 h1:CdNfvlGDggFbyImxlqA2eFUVRKQKn1EJNk7w/3TQAfk=
|
||||
git.rootprojects.org/root/go-gitver/v2 v2.0.1/go.mod h1:ur82M/jZcvr1WWihyVtNEgDBqIjo22o56wcVHeVJFh8=
|
||||
git.rootprojects.org/root/keypairs v0.6.3 h1:dFuDjlg9KFXhRTIyVy3VfXzlX7hyyeC0J8PTUUZBzpo=
|
||||
git.rootprojects.org/root/keypairs v0.6.3/go.mod h1:WGI8PadOp+4LjUuI+wNlSwcJwFtY8L9XuNjuO3213HA=
|
||||
github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec=
|
||||
github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
|
||||
github.com/go-sql-driver/mysql v1.4.0 h1:7LxgVwFb2hIQtMm87NdgAVfXjnt4OePseqT1tKx+opk=
|
||||
|
@ -20,6 +26,7 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec
|
|||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
|
@ -37,10 +44,11 @@ golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
|
|||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200925191224-5d1fdd8fa346 h1:hzJjkvxUIF3bSt+v8N5tBQNx/605vszZJ+3XsIamzZo=
|
||||
golang.org/x/tools v0.0.0-20200925191224-5d1fdd8fa346/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU=
|
||||
golang.org/x/tools v0.0.0-20201001230009-b5b87423c93b h1:07IVqnnzaip3TGyl/cy32V5YP3FguWG4BybYDTBNpm0=
|
||||
golang.org/x/tools v0.0.0-20201001230009-b5b87423c93b/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.6.6 h1:lMO5rYAqUxkmaj76jAkRUvt5JZgFymx/+Q5Mzfivuhc=
|
||||
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
|
|
|
@ -0,0 +1,414 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.rootprojects.org/root/keypairs"
|
||||
"git.rootprojects.org/root/keypairs/keyfetch"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
)
|
||||
|
||||
// TrustProxy will respect X-Forwarded-* headers
|
||||
var TrustProxy bool
|
||||
|
||||
// OIDCWhitelist is a list of allowed issuers
|
||||
var OIDCWhitelist string
|
||||
var issuers keyfetch.Whitelist
|
||||
|
||||
// RandReader is a crypto/rand.Reader by default
|
||||
var RandReader io.Reader = rand.Reader
|
||||
|
||||
var startedAt = time.Now()
|
||||
var defaultMaxBytes int64 = 1 << 20
|
||||
var apiIsReady bool
|
||||
|
||||
// Init will add the API routes to the given router
|
||||
func Init(pub keypairs.PublicKey, r chi.Router) http.Handler {
|
||||
|
||||
// TODO more of this stuff should be options for the API
|
||||
{
|
||||
// block-scoped so we don't keep temp vars around
|
||||
var err error
|
||||
list := strings.Fields(strings.ReplaceAll(strings.TrimSpace(OIDCWhitelist), ",", " "))
|
||||
issuers, err = keyfetch.NewWhitelist(list)
|
||||
if nil != err {
|
||||
log.Fatal("error parsing oidc whitelist:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// OIDC Routes
|
||||
if nil != pub {
|
||||
fmt.Println("Public Key Thumbprint:", pub.Thumbprint())
|
||||
fmt.Println("OIDC enabled at /.well-known/openid-configuration")
|
||||
r.Get("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
|
||||
baseURL := getBaseURL(r)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(fmt.Sprintf(
|
||||
`{ "issuer": "%s", "jwks_uri": "%s/.well-known/jwks.json" }`+"\n",
|
||||
baseURL, baseURL,
|
||||
)))
|
||||
})
|
||||
fmt.Println("JWKs enabled at /.well-known/jwks.json")
|
||||
r.Get("/.well-known/jwks.json", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// non-standard: add expiry for when key should be fetched again
|
||||
// TODO expiry should also go in the HTTP caching headers
|
||||
exp := time.Now().Add(2 * time.Hour)
|
||||
b := pubToOIDC(pub, exp)
|
||||
|
||||
// it's the little things
|
||||
w.Write(append(b, '\n'))
|
||||
})
|
||||
}
|
||||
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.Use(limitResponseSize)
|
||||
r.Use(jsonAllTheThings)
|
||||
|
||||
/*
|
||||
n, err := countAdmins()
|
||||
if nil != err {
|
||||
log.Fatal("could not connect to database on boot:", err)
|
||||
}
|
||||
apiIsReady = n > 0
|
||||
*/
|
||||
|
||||
// Unauthenticated routes
|
||||
r.Route("/public", func(r chi.Router) {
|
||||
r.Post("/setup", publicSetup)
|
||||
|
||||
r.Get("/ping", ping)
|
||||
})
|
||||
|
||||
// Require admin-level permission
|
||||
r.Route("/admin", func(r chi.Router) {
|
||||
r.Use(errorUnlessReady())
|
||||
r.Use(mustAdmin())
|
||||
|
||||
r.Get("/ping", ping)
|
||||
})
|
||||
|
||||
// Any authenticated user
|
||||
r.Route("/user", func(r chi.Router) {
|
||||
r.Use(errorUnlessReady())
|
||||
r.Use(canImpersonate())
|
||||
r.Get("/ping", ping)
|
||||
|
||||
// TODO get ALL of the user's data
|
||||
//r.Get("/", userComplete)
|
||||
})
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// Reset sets the API back to its initial state
|
||||
func Reset() {
|
||||
apiIsReady = false
|
||||
}
|
||||
|
||||
// utils
|
||||
|
||||
func getBaseURL(r *http.Request) string {
|
||||
var scheme string
|
||||
if nil != r.TLS ||
|
||||
(TrustProxy && "https" == r.Header.Get("X-Forwarded-Proto")) {
|
||||
scheme = "https:"
|
||||
} else {
|
||||
scheme = "http:"
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"%s//%s",
|
||||
scheme,
|
||||
r.Host,
|
||||
)
|
||||
}
|
||||
|
||||
func mustAuthn(r *http.Request) (*http.Request, error) {
|
||||
authzParts := strings.Split(r.Header.Get("Authorization"), " ")
|
||||
if 2 != len(authzParts) {
|
||||
return nil, fmt.Errorf("Bad Request: missing Auhorization header")
|
||||
}
|
||||
|
||||
jwt := authzParts[1]
|
||||
// TODO should probably add an error to keypairs
|
||||
jws := keypairs.JWTToJWS(jwt)
|
||||
if nil == jws {
|
||||
return nil, fmt.Errorf("Bad Request: malformed Authorization header")
|
||||
}
|
||||
|
||||
if err := jws.DecodeComponents(); nil != err {
|
||||
return nil, fmt.Errorf("Bad Request: malformed JWT")
|
||||
}
|
||||
|
||||
kid, _ := jws.Header["kid"].(string)
|
||||
if "" == kid {
|
||||
return nil, fmt.Errorf("Bad Request: missing 'kid' identifier")
|
||||
}
|
||||
|
||||
iss, _ := jws.Claims["iss"].(string)
|
||||
// TODO beware domain fronting, we should set domain statically
|
||||
// See https://pkg.go.dev/git.rootprojects.org/root/keypairs@v0.6.2/keyfetch
|
||||
// (Caddy does protect against Domain-Fronting by default:
|
||||
// https://github.com/caddyserver/caddy/issues/2500)
|
||||
if "" == iss || !issuers.IsTrustedIssuer(iss, r) {
|
||||
return nil, fmt.Errorf("Bad Request: 'iss' is not a trusted issuer")
|
||||
}
|
||||
|
||||
pub, err := keyfetch.OIDCJWK(kid, iss)
|
||||
if nil != err {
|
||||
return nil, fmt.Errorf("Bad Request: 'kid' could not be matched to a known public key")
|
||||
}
|
||||
|
||||
errs := keypairs.VerifyClaims(pub, jws)
|
||||
if nil != errs {
|
||||
strs := []string{}
|
||||
for _, err := range errs {
|
||||
strs = append(strs, err.Error())
|
||||
}
|
||||
return nil, fmt.Errorf("invalid jwt:\n%s", strings.Join(strs, "\n\t"))
|
||||
}
|
||||
|
||||
email, _ := jws.Claims["email"].(string)
|
||||
ppid, _ := jws.Claims["sub"].(string)
|
||||
if "" == email || "" == ppid {
|
||||
return nil, fmt.Errorf("valid signed token, but missing claim for either 'email' or 'sub'")
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), userPPID, ppid)
|
||||
return r.WithContext(ctx), nil
|
||||
}
|
||||
|
||||
func pubToOIDC(pub keypairs.PublicKey, exp time.Time) []byte {
|
||||
exps := strconv.FormatInt(exp.Unix(), 10)
|
||||
jsons := string(keypairs.MarshalJWKPublicKey(pub))
|
||||
|
||||
// this isn't as fragile as it looks, just adding some OIDC keys and such
|
||||
|
||||
// make prettier
|
||||
jsons = strings.Replace(jsons, `{"`, `{ "`, 1)
|
||||
jsons = strings.Replace(jsons, `",`, `" ,`, -1)
|
||||
|
||||
// nix trailing }
|
||||
jsons = jsons[0 : len(jsons)-1]
|
||||
// add on the OIDC stuff (exp is non-standard, but used by pocketid)
|
||||
jsons = `{ "keys": [ ` +
|
||||
jsons + fmt.Sprintf(`, "ext": true , "key_ops": ["verify"], "exp": %s }`, exps) +
|
||||
" ] }"
|
||||
|
||||
return []byte(jsons)
|
||||
}
|
||||
|
||||
// HTTPResponse gives a basic status message
|
||||
// TODO sanitize all error messages and define error codes
|
||||
type HTTPResponse struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Success bool `json:"success"`
|
||||
Data interface{} `json:"result,omitempty"`
|
||||
}
|
||||
|
||||
func ping(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
// as of yet, only known to be a user
|
||||
ppid, _ := ctx.Value(userPPID).(string)
|
||||
|
||||
w.Write([]byte(fmt.Sprintf(
|
||||
`{ "success": true, "uptime": %.0f, "ppid": %q }`+"\n",
|
||||
time.Since(startedAt).Seconds(), ppid,
|
||||
)))
|
||||
}
|
||||
|
||||
func noImpl(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(
|
||||
`{ "success": false, "error": "not implemented" }` + "\n",
|
||||
))
|
||||
}
|
||||
|
||||
func publicSetup(w http.ResponseWriter, r *http.Request) {
|
||||
if apiIsReady {
|
||||
// default is already 404, methinks
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
r, err := mustAuthn(r)
|
||||
if nil != err {
|
||||
userError(w, err)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
ppid := ctx.Value(userPPID).(string)
|
||||
if "" == ppid {
|
||||
if nil != err {
|
||||
userError(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := adminBootstrap(ppid); nil != err {
|
||||
serverError("publicSetup.bootstrap", w, err)
|
||||
return
|
||||
}
|
||||
apiIsReady = true
|
||||
|
||||
w.Write([]byte(fmt.Sprintf(
|
||||
`{ "success": true, "sub": %q }`+"\n",
|
||||
ppid,
|
||||
)))
|
||||
}
|
||||
|
||||
func reply(w http.ResponseWriter, msg interface{}) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
b, _ := json.MarshalIndent(&HTTPResponse{
|
||||
Success: true,
|
||||
Data: msg,
|
||||
}, "", " ")
|
||||
w.Write(append(b, '\n'))
|
||||
}
|
||||
|
||||
func userError(w http.ResponseWriter, err error) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
b, _ := json.Marshal(&HTTPResponse{
|
||||
Error: err.Error(),
|
||||
})
|
||||
w.Write(append(b, '\n'))
|
||||
}
|
||||
|
||||
func dbError(hint string, w http.ResponseWriter, err error) {
|
||||
serverError(hint, w, err)
|
||||
}
|
||||
|
||||
func serverError(hint string, w http.ResponseWriter, err error) {
|
||||
// TODO check constraint errors and such, as those are likely user errors
|
||||
if sql.ErrNoRows == err {
|
||||
userError(w, fmt.Errorf("E_NOT_FOUND: %s", err))
|
||||
return
|
||||
} else if strings.Contains(err.Error(), "constraint") {
|
||||
userError(w, fmt.Errorf("E_DUPLICATE_NAME: %s", err))
|
||||
return
|
||||
}
|
||||
log.Printf("[%s] error: %v", hint, err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
b, _ := json.Marshal(&HTTPResponse{
|
||||
Error: err.Error(),
|
||||
})
|
||||
w.Write(append(b, '\n'))
|
||||
}
|
||||
|
||||
var errSetID = errors.New("you may not set the player's ID")
|
||||
|
||||
// Middleware
|
||||
|
||||
func errorUnlessReady() func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !apiIsReady {
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type adminCtx string
|
||||
|
||||
const (
|
||||
adminPPID adminCtx = "ppid"
|
||||
)
|
||||
|
||||
type userCtx string
|
||||
|
||||
const (
|
||||
userPPID userCtx = "ppid"
|
||||
)
|
||||
|
||||
func canImpersonate() func(next http.Handler) http.Handler {
|
||||
return actAsAdmin(false)
|
||||
}
|
||||
|
||||
func mustAdmin() func(next http.Handler) http.Handler {
|
||||
return actAsAdmin(true)
|
||||
}
|
||||
|
||||
func countAdmins() (int, error) {
|
||||
return 0, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func adminBootstrap(ppid string) error {
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
|
||||
func actAsAdmin(must bool) func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var err error
|
||||
r, err = mustAuthn(r)
|
||||
if nil != err {
|
||||
userError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
// as of yet, only known to be a user
|
||||
ppid, _ := ctx.Value(userPPID).(string)
|
||||
|
||||
//ok, err := isAdmin(ppid)
|
||||
ok, err := false, errors.New("not implemented")
|
||||
if nil != err {
|
||||
serverError("actAsAdmin", w, err)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
if must {
|
||||
userError(w, errors.New("you're not an admin"))
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// we now know this is an admin, adjust accordingly
|
||||
ctx = context.WithValue(r.Context(), adminPPID, ppid)
|
||||
|
||||
// also, an admin can impersonate
|
||||
//uemail := r.URL.Query().Get("user_email")
|
||||
uppid := r.URL.Query().Get("manager_id")
|
||||
if "" == uppid {
|
||||
uppid = ppid
|
||||
}
|
||||
|
||||
ctx = context.WithValue(ctx, userPPID, uppid)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func jsonAllTheThings(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// just setting a default, other handlers can change this
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func limitResponseSize(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, defaultMaxBytes)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
|
@ -0,0 +1,164 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
mathrand "math/rand"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.example.com/example/goserv/internal/db"
|
||||
"git.rootprojects.org/root/keypairs"
|
||||
"git.rootprojects.org/root/keypairs/keyfetch"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
)
|
||||
|
||||
var srv *httptest.Server
|
||||
|
||||
var testKey keypairs.PrivateKey
|
||||
var testPub keypairs.PublicKey
|
||||
var testWhitelist keyfetch.Whitelist
|
||||
|
||||
func init() {
|
||||
// In tests it's nice to get the same "random" values, every time
|
||||
RandReader = testReader{}
|
||||
mathrand.Seed(0)
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
connStr := needsTestDB(m)
|
||||
if strings.Contains(connStr, "@localhost/") || strings.Contains(connStr, "@localhost:") {
|
||||
connStr += "?sslmode=disable"
|
||||
} else {
|
||||
connStr += "?sslmode=required"
|
||||
}
|
||||
|
||||
if err := db.Init(connStr); nil != err {
|
||||
log.Fatal("db connection error", err)
|
||||
return
|
||||
}
|
||||
if err := db.DropAllTables(db.PleaseDoubleCheckTheDatabaseURLDontDropProd(connStr)); nil != err {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if err := db.Init(connStr); nil != err {
|
||||
log.Fatal("db connection error", err)
|
||||
return
|
||||
}
|
||||
|
||||
var err error
|
||||
testKey = keypairs.NewDefaultPrivateKey()
|
||||
testPub = keypairs.NewPublicKey(testKey.Public())
|
||||
r := chi.NewRouter()
|
||||
srv = httptest.NewServer(Init(testPub, r))
|
||||
testWhitelist, err = keyfetch.NewWhitelist(nil, []string{srv.URL})
|
||||
if nil != err {
|
||||
log.Fatal("bad whitelist", err)
|
||||
return
|
||||
}
|
||||
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
// public APIs
|
||||
|
||||
func Test_Public_Ping(t *testing.T) {
|
||||
if err := testPing("public"); nil != err {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// test types
|
||||
|
||||
type testReader struct{}
|
||||
|
||||
func (testReader) Read(p []byte) (n int, err error) {
|
||||
return mathrand.Read(p)
|
||||
}
|
||||
|
||||
func testPing(which string) error {
|
||||
urlstr := fmt.Sprintf("/api/%s/ping", which)
|
||||
res, err := testReq("GET", urlstr, "", nil, 200)
|
||||
if nil != err {
|
||||
return err
|
||||
}
|
||||
|
||||
data := map[string]interface{}{}
|
||||
if err := json.NewDecoder(res.Body).Decode(&data); nil != err {
|
||||
return err
|
||||
}
|
||||
|
||||
if success, ok := data["success"].(bool); !ok || !success {
|
||||
log.Printf("Bad Response\n\tURL:%s\n\tBody:\n%#v", urlstr, data)
|
||||
return errors.New("bad response: missing success")
|
||||
}
|
||||
|
||||
if ppid, _ := data["ppid"].(string); "" != ppid {
|
||||
return fmt.Errorf("the effective user ID isn't what it should be: %q != %q", ppid, "")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func testReq(method, pathname string, jwt string, payload []byte, expectedStatus int) (*http.Response, error) {
|
||||
client := srv.Client()
|
||||
urlstr, _ := url.Parse(srv.URL + pathname)
|
||||
|
||||
if "" == method {
|
||||
method = "GET"
|
||||
}
|
||||
|
||||
req := &http.Request{
|
||||
Method: method,
|
||||
URL: urlstr,
|
||||
Body: ioutil.NopCloser(bytes.NewReader(payload)),
|
||||
Header: http.Header{},
|
||||
}
|
||||
|
||||
if len(jwt) > 0 {
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", jwt))
|
||||
}
|
||||
res, err := client.Do(req)
|
||||
if nil != err {
|
||||
return nil, err
|
||||
}
|
||||
if expectedStatus > 0 {
|
||||
if expectedStatus != res.StatusCode {
|
||||
data, _ := ioutil.ReadAll(res.Body)
|
||||
log.Printf("Bad Response: %d\n\tURL:%s\n\tBody:\n%s", res.StatusCode, urlstr, string(data))
|
||||
return nil, fmt.Errorf("bad status code: %d", res.StatusCode)
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func needsTestDB(m *testing.M) string {
|
||||
connStr := os.Getenv("TEST_DATABASE_URL")
|
||||
if "" == connStr {
|
||||
log.Fatal(`no connection string defined
|
||||
|
||||
You must set TEST_DATABASE_URL to run db tests.
|
||||
|
||||
You may find this helpful:
|
||||
|
||||
psql 'postgres://postgres:postgres@localhost:5432/postgres'
|
||||
|
||||
DROP DATABASE IF EXISTS postgres_test;
|
||||
CREATE DATABASE postgres_test;
|
||||
\q
|
||||
|
||||
Then your test database URL will be
|
||||
|
||||
export TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres_test
|
||||
`)
|
||||
}
|
||||
return connStr
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"time"
|
||||
|
||||
"git.example.com/example/goserv/internal/db"
|
||||
)
|
||||
|
||||
func newID() string {
|
||||
// Postgres returns IDs on inserts but,
|
||||
// for portability and ease of association,
|
||||
// we'll create our own.
|
||||
b := make([]byte, 16)
|
||||
_, _ = RandReader.Read(b)
|
||||
id := base64.RawURLEncoding.EncodeToString(b)
|
||||
return id
|
||||
}
|
||||
|
||||
// NotDeleted supplements a WHERE clause
|
||||
const NotDeleted = `
|
||||
( "deleted_at" IS NULL OR "deleted_at" = '0001-01-01 00:00:00+00' OR "deleted_at" = '1970-01-01 00:00:00+00' )
|
||||
`
|
||||
|
||||
func logEvent(action, table, recordID, by string, at time.Time) (string, error) {
|
||||
id := newID()
|
||||
|
||||
if _, err := db.DB.Exec(`
|
||||
INSERT INTO "events" ("id", "action", "table", "record", "by", "at")
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
id, action, table, recordID, by, at,
|
||||
); nil != err {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return id, nil
|
||||
}
|
|
@ -3,10 +3,13 @@ package db
|
|||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"regexp"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"git.coolaj86.com/coolaj86/goserv/assets/configfs"
|
||||
"git.example.com/example/goserv/assets/configfs"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
// pq injects itself into sql as 'postgres'
|
||||
|
@ -15,21 +18,14 @@ import (
|
|||
|
||||
// DB is a concurrency-safe db connection instance
|
||||
var DB *sqlx.DB
|
||||
var firstDBURL PleaseDoubleCheckTheDatabaseURLDontDropProd
|
||||
|
||||
// Init returns a, you guessed it, New Store
|
||||
// Init initializes the database
|
||||
func Init(pgURL string) error {
|
||||
// https://godoc.org/github.com/lib/pq
|
||||
|
||||
f, err := configfs.Assets.Open("./postgres/init.sql")
|
||||
if nil != err {
|
||||
return err
|
||||
}
|
||||
|
||||
firstDBURL = PleaseDoubleCheckTheDatabaseURLDontDropProd(pgURL)
|
||||
dbtype := "postgres"
|
||||
sqlBytes, err := ioutil.ReadAll(f)
|
||||
if nil != err {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, done := context.WithDeadline(context.Background(), time.Now().Add(5*time.Second))
|
||||
defer done()
|
||||
|
@ -37,6 +33,29 @@ func Init(pgURL string) error {
|
|||
if err := db.PingContext(ctx); nil != err {
|
||||
return err
|
||||
}
|
||||
|
||||
// basic stuff
|
||||
f, err := configfs.Assets.Open("./postgres/init.sql")
|
||||
if nil != err {
|
||||
return err
|
||||
}
|
||||
sqlBytes, err := ioutil.ReadAll(f)
|
||||
if nil != err {
|
||||
return err
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, string(sqlBytes)); nil != err {
|
||||
return err
|
||||
}
|
||||
|
||||
// project-specific stuff
|
||||
f, err = configfs.Assets.Open("./postgres/tables.sql")
|
||||
if nil != err {
|
||||
return err
|
||||
}
|
||||
sqlBytes, err = ioutil.ReadAll(f)
|
||||
if nil != err {
|
||||
return err
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, string(sqlBytes)); nil != err {
|
||||
return err
|
||||
}
|
||||
|
@ -45,3 +64,58 @@ func Init(pgURL string) error {
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PleaseDoubleCheckTheDatabaseURLDontDropProd is just a friendly,
|
||||
// hopefully helpful reminder, not to only use this in test files,
|
||||
// and to not drop the production database
|
||||
type PleaseDoubleCheckTheDatabaseURLDontDropProd string
|
||||
|
||||
// DropAllTables runs drop.sql, which is intended only for tests
|
||||
func DropAllTables(dbURL PleaseDoubleCheckTheDatabaseURLDontDropProd) error {
|
||||
if err := CanDropAllTables(string(dbURL)); nil != err {
|
||||
return err
|
||||
}
|
||||
|
||||
// drop stuff
|
||||
f, err := configfs.Assets.Open("./postgres/drop.sql")
|
||||
if nil != err {
|
||||
return err
|
||||
}
|
||||
sqlBytes, err := ioutil.ReadAll(f)
|
||||
if nil != err {
|
||||
return err
|
||||
}
|
||||
ctx, done := context.WithDeadline(context.Background(), time.Now().Add(1*time.Second))
|
||||
defer done()
|
||||
if _, err := DB.ExecContext(ctx, string(sqlBytes)); nil != err {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CanDropAllTables returns an error if the dbURL does not contain the words "test" or
|
||||
// "demo" at a letter boundary
|
||||
func CanDropAllTables(dbURL string) error {
|
||||
var isDemo bool
|
||||
nonalpha := regexp.MustCompile(`[^a-zA-Z]`)
|
||||
haystack := nonalpha.Split(dbURL, -1)
|
||||
sort.Strings(haystack)
|
||||
for _, needle := range []string{"test", "demo"} {
|
||||
// the index to insert x if x is not present (it could be len(a))
|
||||
// (meaning that it is the index at which it exists, if it exists)
|
||||
i := sort.SearchStrings(haystack, needle)
|
||||
if i < len(haystack) && haystack[i] == needle {
|
||||
isDemo = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if isDemo {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf(
|
||||
"test and demo database URLs must contain the word 'test' or 'demo' "+
|
||||
"separated by a non-alphabet character, such as /test2/db_demo1\n%q\n",
|
||||
dbURL,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,66 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
if err := testConnectAndInit(); nil != err {
|
||||
fmt.Fprintf(os.Stderr, err.Error())
|
||||
os.Exit(1)
|
||||
return
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func needsTestDB() (string, error) {
|
||||
connStr := os.Getenv("TEST_DATABASE_URL")
|
||||
if "" == connStr {
|
||||
return "", errors.New(`no connection string defined
|
||||
|
||||
You must set TEST_DATABASE_URL to run db tests.
|
||||
|
||||
You may find this helpful:
|
||||
|
||||
psql 'postgres://postgres:postgres@localhost:5432/postgres'
|
||||
|
||||
DROP DATABASE IF EXISTS postgres_test;
|
||||
CREATE DATABASE postgres_test;
|
||||
\q
|
||||
|
||||
Then your test database URL will be
|
||||
|
||||
export TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres_test`)
|
||||
}
|
||||
return connStr, nil
|
||||
}
|
||||
|
||||
func testConnectAndInit() error {
|
||||
connStr, err := needsTestDB()
|
||||
if nil != err {
|
||||
return err
|
||||
}
|
||||
if strings.Contains(connStr, "@localhost/") || strings.Contains(connStr, "@localhost:") {
|
||||
connStr += "?sslmode=disable"
|
||||
} else {
|
||||
connStr += "?sslmode=required"
|
||||
}
|
||||
|
||||
if err := Init(connStr); nil != err {
|
||||
return fmt.Errorf("db connection error: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestDropAll(t *testing.T) {
|
||||
connStr := os.Getenv("TEST_DATABASE_URL")
|
||||
|
||||
if err := DropAllTables(PleaseDoubleCheckTheDatabaseURLDontDropProd(connStr)); nil != err {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
237
main.go
237
main.go
|
@ -1,20 +1,27 @@
|
|||
//go:generate go run git.rootprojects.org/root/go-gitver/v2
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"compress/flate"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.coolaj86.com/coolaj86/goserv/assets"
|
||||
"git.coolaj86.com/coolaj86/goserv/internal/db"
|
||||
"git.example.com/example/goserv/assets"
|
||||
"git.example.com/example/goserv/internal/api"
|
||||
"git.example.com/example/goserv/internal/db"
|
||||
"git.rootprojects.org/root/keypairs"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/go-chi/chi/middleware"
|
||||
|
||||
_ "github.com/joho/godotenv/autoload"
|
||||
)
|
||||
|
||||
|
@ -45,6 +52,9 @@ type runOptions struct {
|
|||
trustProxy bool
|
||||
compress bool
|
||||
static string
|
||||
pub string
|
||||
oidcWL string
|
||||
demo bool
|
||||
}
|
||||
|
||||
var runFlags *flag.FlagSet
|
||||
|
@ -53,17 +63,32 @@ var initFlags *flag.FlagSet
|
|||
var dbURL string
|
||||
|
||||
func init() {
|
||||
// chosen by fair dice roll.
|
||||
// guaranteed to be random.
|
||||
rand.Seed(4)
|
||||
|
||||
// j/k
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
initFlags = flag.NewFlagSet("init", flag.ExitOnError)
|
||||
var conftodo bool
|
||||
var confdomain string
|
||||
initFlags.BoolVar(&conftodo, "todo", false, "TODO init should copy out nice templated config files")
|
||||
initFlags.StringVar(&confdomain, "base-url", "https://example.com", "TODO the domain to use for templated scripts")
|
||||
|
||||
runOpts = runOptions{}
|
||||
runFlags = flag.NewFlagSet("run", flag.ExitOnError)
|
||||
runFlags.StringVar(
|
||||
&runOpts.listen, "listen", "",
|
||||
"the address and port on which to listen (default \""+defaultAddr+"\")")
|
||||
runFlags.BoolVar(&runOpts.trustProxy, "trust-proxy", false, "trust X-Forwarded-For header")
|
||||
runFlags.BoolVar(&runOpts.trustProxy, "trust-proxy", false, "trust X-Forwarded-* headers")
|
||||
runFlags.BoolVar(&runOpts.compress, "compress", true, "enable compression for text,html,js,css,etc")
|
||||
runFlags.StringVar(&runOpts.static, "serve-path", "", "path to serve, falls back to built-in web app")
|
||||
runFlags.StringVar(
|
||||
&dbURL, "db-url", "postgres://postgres:postgres@localhost:5432/postgres",
|
||||
"database (postgres) connection url")
|
||||
runFlags.StringVar(&runOpts.pub, "public-key", "", "path to public key, or key string - RSA or ECDSA, JWK (JSON) or PEM")
|
||||
runFlags.StringVar(&runOpts.oidcWL, "oidc-whitelist", "", "list of trusted OIDC issuer URLs (ex: Auth0, Google, PocketID) for SSO")
|
||||
runFlags.BoolVar(&runOpts.demo, "demo", false, "demo mode enables unauthenticated 'DELETE /api/public/reset' to reset")
|
||||
runFlags.StringVar(&dbURL, "database-url", "",
|
||||
"database (postgres) connection url (default postgres://postgres:postgres@localhost:5432/postgres)",
|
||||
)
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
@ -106,6 +131,19 @@ func main() {
|
|||
runOpts.listen = defaultAddr
|
||||
}
|
||||
}
|
||||
if "" == dbURL {
|
||||
dbURL = os.Getenv("DATABASE_URL")
|
||||
}
|
||||
if "" == dbURL {
|
||||
dbURL = "postgres://postgres:postgres@localhost:5432/postgres"
|
||||
}
|
||||
api.OIDCWhitelist = runOpts.oidcWL
|
||||
if "" == api.OIDCWhitelist {
|
||||
api.OIDCWhitelist = os.Getenv("OIDC_WHITELIST")
|
||||
}
|
||||
if "" == runOpts.pub {
|
||||
runOpts.pub = os.Getenv("PUBLIC_KEY")
|
||||
}
|
||||
serve()
|
||||
default:
|
||||
usage()
|
||||
|
@ -114,100 +152,6 @@ func main() {
|
|||
}
|
||||
}
|
||||
|
||||
var startedAt = time.Now()
|
||||
var defaultMaxBytes int64 = 1 << 20
|
||||
|
||||
func serve() {
|
||||
initDB(dbURL)
|
||||
|
||||
r := chi.NewRouter()
|
||||
|
||||
// A good base middleware stack
|
||||
if runOpts.trustProxy {
|
||||
r.Use(middleware.RealIP)
|
||||
}
|
||||
if runOpts.compress {
|
||||
r.Use(middleware.Compress(flate.DefaultCompression))
|
||||
}
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.Use(limitResponseSize)
|
||||
r.Use(jsonAllTheThings)
|
||||
|
||||
r.Route("/public", func(r chi.Router) {
|
||||
r.Get("/status", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(fmt.Sprintf(
|
||||
`{ "success": true, "uptime": %.0f }%s`,
|
||||
time.Since(startedAt).Seconds(),
|
||||
"\n",
|
||||
)))
|
||||
})
|
||||
})
|
||||
|
||||
r.Route("/user", func(r chi.Router) {
|
||||
r.Get("/inspect", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(fmt.Sprintf(
|
||||
`{ "success": false, "error": "not implemented" }%s`, "\n",
|
||||
)))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
var staticHandler http.HandlerFunc
|
||||
pub := http.FileServer(assets.Assets)
|
||||
|
||||
if len(runOpts.static) > 0 {
|
||||
// try the user-provided directory first, then fallback to the built-in
|
||||
devFS := http.Dir(runOpts.static)
|
||||
dev := http.FileServer(devFS)
|
||||
staticHandler = func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, err := devFS.Open(r.URL.Path); nil != err {
|
||||
pub.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
dev.ServeHTTP(w, r)
|
||||
}
|
||||
} else {
|
||||
staticHandler = func(w http.ResponseWriter, r *http.Request) {
|
||||
pub.ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
r.Get("/*", staticHandler)
|
||||
|
||||
fmt.Println("Listening for http (with reasonable timeouts) on", runOpts.listen)
|
||||
srv := &http.Server{
|
||||
Addr: runOpts.listen,
|
||||
Handler: r,
|
||||
ReadHeaderTimeout: 2 * time.Second,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 20 * time.Second,
|
||||
MaxHeaderBytes: 1024 * 1024, // 1MiB
|
||||
}
|
||||
if err := srv.ListenAndServe(); nil != err {
|
||||
fmt.Fprintf(os.Stderr, "%s", err)
|
||||
os.Exit(1)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func jsonAllTheThings(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// just setting a default, other handlers can change this
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func limitResponseSize(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, defaultMaxBytes)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func initDB(connStr string) {
|
||||
// TODO url.Parse
|
||||
if strings.Contains(connStr, "@localhost/") || strings.Contains(connStr, "@localhost:") {
|
||||
|
@ -225,3 +169,96 @@ func initDB(connStr string) {
|
|||
|
||||
return
|
||||
}
|
||||
|
||||
func serve() {
|
||||
initDB(dbURL)
|
||||
|
||||
r := chi.NewRouter()
|
||||
|
||||
// A good base middleware stack
|
||||
if runOpts.trustProxy {
|
||||
api.TrustProxy = true
|
||||
r.Use(middleware.RealIP)
|
||||
}
|
||||
if runOpts.compress {
|
||||
r.Use(middleware.Compress(flate.DefaultCompression))
|
||||
}
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
|
||||
var pub keypairs.PublicKey = nil
|
||||
if "" != runOpts.pub {
|
||||
var err error
|
||||
pub, err = keypairs.ParsePublicKey([]byte(runOpts.pub))
|
||||
if nil != err {
|
||||
b, err := ioutil.ReadFile(runOpts.pub)
|
||||
if nil != err {
|
||||
// ignore
|
||||
} else {
|
||||
pub, err = keypairs.ParsePublicKey(b)
|
||||
if nil != err {
|
||||
log.Fatal("could not parse public key:", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = api.Init(pub, r)
|
||||
|
||||
var staticHandler http.HandlerFunc
|
||||
pubdir := http.FileServer(assets.Assets)
|
||||
|
||||
if len(runOpts.static) > 0 {
|
||||
// try the user-provided directory first, then fallback to the built-in
|
||||
devFS := http.Dir(runOpts.static)
|
||||
dev := http.FileServer(devFS)
|
||||
staticHandler = func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, err := devFS.Open(r.URL.Path); nil != err {
|
||||
pubdir.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
dev.ServeHTTP(w, r)
|
||||
}
|
||||
} else {
|
||||
staticHandler = func(w http.ResponseWriter, r *http.Request) {
|
||||
pubdir.ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
if runOpts.demo {
|
||||
if err := db.CanDropAllTables(dbURL); nil != err {
|
||||
fmt.Fprintf(os.Stderr, err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("DANGER: running in demo mode with DELETE /api/public/reset enabled")
|
||||
fmt.Fprintf(os.Stderr, "DANGER: running in demo mode with DELETE /api/public/reset enabled\n")
|
||||
r.Delete("/api/public/reset", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := db.DropAllTables(db.PleaseDoubleCheckTheDatabaseURLDontDropProd(dbURL)); nil != err {
|
||||
w.Write([]byte("error dropping tabels: " + err.Error()))
|
||||
return
|
||||
}
|
||||
api.Reset()
|
||||
initDB(dbURL)
|
||||
w.Write([]byte("re-initialized\n"))
|
||||
})
|
||||
}
|
||||
|
||||
r.Get("/*", staticHandler)
|
||||
|
||||
fmt.Println("")
|
||||
fmt.Println("Listening for http (with reasonable timeouts) on", runOpts.listen)
|
||||
srv := &http.Server{
|
||||
Addr: runOpts.listen,
|
||||
Handler: r,
|
||||
ReadHeaderTimeout: 2 * time.Second,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 20 * time.Second,
|
||||
MaxHeaderBytes: 1024 * 1024, // 1MiB
|
||||
}
|
||||
if err := srv.ListenAndServe(); nil != err {
|
||||
fmt.Fprintf(os.Stderr, "%s\n", err)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
os.Exit(1)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,82 +1,76 @@
|
|||
<!DOCTYPE htmtl>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Example</title>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="./vendor/css/bootswatch.com/4/materia/bootstrap.min.css"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
|
||||
<a class="navbar-brand" href="#">Example</a>
|
||||
<button
|
||||
class="navbar-toggler"
|
||||
type="button"
|
||||
data-toggle="collapse"
|
||||
data-target="#navbarColor01"
|
||||
aria-controls="navbarColor01"
|
||||
aria-expanded="false"
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Example</title>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="./vendor/css/bootswatch.com/4/materia/bootstrap.min.css"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
|
||||
<a class="navbar-brand" href="#">Example</a>
|
||||
<button
|
||||
class="navbar-toggler"
|
||||
type="button"
|
||||
data-toggle="collapse"
|
||||
data-target="#navbarColor01"
|
||||
aria-controls="navbarColor01"
|
||||
aria-expanded="false"
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<div class="collapse navbar-collapse" id="navbarColor01">
|
||||
<ul class="navbar-nav mr-auto">
|
||||
<li class="nav-item active">
|
||||
<a class="nav-link" href="#nav-foobar">Foobar</a>
|
||||
</li>
|
||||
</ul>
|
||||
<form class="js-signin form-inline my-2 my-lg-0">
|
||||
<input
|
||||
class="form-control mr-sm-2"
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="email"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-secondary my-2 my-sm-0"
|
||||
type="submit"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarColor01">
|
||||
<ul class="navbar-nav mr-auto">
|
||||
<li class="nav-item active">
|
||||
<a class="nav-link" href="#nav-foobar">Foobar</a>
|
||||
</li>
|
||||
</ul>
|
||||
<form class="js-signin form-inline my-2 my-lg-0">
|
||||
<input
|
||||
class="form-control mr-sm-2"
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="email"
|
||||
/>
|
||||
<button class="btn btn-secondary my-2 my-sm-0" type="submit">
|
||||
Sign in
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="pocket"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
<div class="card border-primary mb-6">
|
||||
<a id="nav-foobar"
|
||||
><h3 class="card-header">
|
||||
Server Health
|
||||
<form class="js-healthcheck">
|
||||
<button type="submit" class="float-right btn btn-primary">
|
||||
Check
|
||||
</button>
|
||||
</form>
|
||||
</h3></a
|
||||
>
|
||||
<div class="js-pre card-body">
|
||||
<h5 class="card-title">Check Server Status</h5>
|
||||
<div class="card-text">
|
||||
<pre><code>curl https://example.com/api/public/ping</code></pre>
|
||||
<pre><code class="js-server-health">-</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="pocket"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
<div class="card border-primary mb-6">
|
||||
<a id="nav-foobar"
|
||||
><h3 class="card-header">
|
||||
Server Health
|
||||
<form class="js-healthcheck">
|
||||
<button
|
||||
type="submit"
|
||||
class="float-right btn btn-primary"
|
||||
>
|
||||
Check
|
||||
</button>
|
||||
</form>
|
||||
</h3></a
|
||||
>
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Check Server Status</h5>
|
||||
<div class="card-text">
|
||||
<pre><code>curl https://example.com/api/public/status</code></pre>
|
||||
<pre><code class="js-server-health">-</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://mock.pocketid.app/pocket/consumer.js"></script>
|
||||
<script src="./js/app.js"></script>
|
||||
</body>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://mock.pocketid.app/pocket/consumer.js"></script>
|
||||
<script src="./js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
134
public/js/app.js
134
public/js/app.js
|
@ -1,49 +1,111 @@
|
|||
(function () {
|
||||
"use strict";
|
||||
"use strict";
|
||||
|
||||
// AJQuery
|
||||
function $(sel, el) {
|
||||
if (!el) {
|
||||
el = document;
|
||||
}
|
||||
return el.querySelector(sel);
|
||||
// AJQuery
|
||||
function $(sel, el) {
|
||||
if (!el) {
|
||||
el = document;
|
||||
}
|
||||
function $$(sel, el) {
|
||||
if (!el) {
|
||||
el = document;
|
||||
}
|
||||
return el.querySelectorAll(sel);
|
||||
return el.querySelector(sel);
|
||||
}
|
||||
function $$(sel, el) {
|
||||
if (!el) {
|
||||
el = document;
|
||||
}
|
||||
return el.querySelectorAll(sel);
|
||||
}
|
||||
|
||||
function displayToken(token) {
|
||||
$$(".js-token").forEach(function (el) {
|
||||
el.innerText = token;
|
||||
});
|
||||
}
|
||||
|
||||
Pocket.onToken(function (token) {
|
||||
// TODO Pocket v1.0 will make this obsolete
|
||||
localStorage.setItem("pocket-token", token);
|
||||
displayToken();
|
||||
function displayToken(token) {
|
||||
$$(".js-token").forEach(function (el) {
|
||||
el.innerText = token;
|
||||
});
|
||||
displayToken(localStorage.getItem("pocket-token"));
|
||||
}
|
||||
|
||||
// requires div with class 'pocket'
|
||||
$("form.js-signin").addEventListener("submit", function (ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
Pocket.onToken(function (token) {
|
||||
// TODO Pocket v1.0 will make this obsolete
|
||||
localStorage.setItem("pocket-token", token);
|
||||
displayToken();
|
||||
});
|
||||
displayToken(localStorage.getItem("pocket-token"));
|
||||
|
||||
var email = $("[name=email]").value;
|
||||
Pocket.openSignin(ev, { email: email });
|
||||
// requires div with class 'pocket'
|
||||
$("form.js-signin").addEventListener("submit", function (ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
var email = $("[name=email]").value;
|
||||
Pocket.openSignin(ev, { email: email });
|
||||
});
|
||||
|
||||
$("form.js-healthcheck").addEventListener("submit", function (ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
window.fetch("/api/public/ping").then(async function (resp) {
|
||||
var res = await resp.json();
|
||||
$(".js-server-health").innerText = JSON.stringify(res, null, 2);
|
||||
});
|
||||
});
|
||||
`
|
||||
# Demo Mode Only
|
||||
DELETE /public/reset Drop database and re-initialize
|
||||
|
||||
$("form.js-healthcheck").addEventListener("submit", function (ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
# Public
|
||||
GET /public/ping Health Check
|
||||
POST /public/setup <= (none) Bootstrap
|
||||
|
||||
window.fetch("/api/public/status").then(async function (resp) {
|
||||
var res = await resp.json();
|
||||
$(".js-server-health").innerText = JSON.stringify(res, null, 2);
|
||||
});
|
||||
# Admin-only
|
||||
GET /admin/ping (authenticated) Health Check
|
||||
GET /admin/users []Users => List ALL Users
|
||||
|
||||
# User
|
||||
GET /user/ping (authenticated) Health Check
|
||||
GET /user => User User profile
|
||||
`
|
||||
.trim()
|
||||
.split(/\n/)
|
||||
.forEach(function (line) {
|
||||
line = line.trim();
|
||||
if ("#" === line[0] || !line.trim()) {
|
||||
return;
|
||||
}
|
||||
line = line.replace(/(<=)?\s*\(none\)\s*(=>)?/g, "");
|
||||
var parts = line.split(/\s+/g);
|
||||
var method = parts[0];
|
||||
if ("GET" != method) {
|
||||
method = "-X " + method + " ";
|
||||
} else {
|
||||
method = "";
|
||||
}
|
||||
var pathname = parts[1];
|
||||
var auth = pathname.match(/(public|user|admin)/)[1];
|
||||
if ("admin" == auth) {
|
||||
auth = " \\\n -H 'Authorization: Bearer ADMIN_TOKEN'";
|
||||
} else if ("user" == auth) {
|
||||
auth = " \\\n -H 'Authorization: Bearer USER_TOKEN'";
|
||||
} else {
|
||||
auth = "";
|
||||
}
|
||||
document.body.querySelector(".js-pre").innerHTML += (
|
||||
`
|
||||
<div class="card-text">
|
||||
<pre><code>curl -X POST https://example.com/api</code></pre>
|
||||
<pre><code class="js-` +
|
||||
pathname.replace(/\//g, "-") +
|
||||
`">-</code></pre>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.replace(
|
||||
/https:\/\/example\.com/g,
|
||||
location.protocol + "//" + location.host
|
||||
)
|
||||
.replace(/-X POST /g, method)
|
||||
.replace(/\/api/g, "/api" + pathname + auth);
|
||||
});
|
||||
/*
|
||||
document.body.querySelector(".js-pre").innerHTML = document.body
|
||||
.querySelector(".js-pre")
|
||||
.innerHTML
|
||||
*/
|
||||
})();
|
||||
|
|
|
@ -7,4 +7,7 @@ import (
|
|||
// these are 'go generate' tooling dependencies, not including in the binary
|
||||
_ "github.com/shurcooL/vfsgen"
|
||||
_ "github.com/shurcooL/vfsgen/cmd/vfsgendev"
|
||||
_ "golang.org/x/tools/cmd/stringer"
|
||||
_ "git.rootprojects.org/root/keypairs/cmd/keypairs"
|
||||
_ "git.rootprojects.org/root/go-gitver/v2"
|
||||
)
|
||||
|
|
|
@ -1,3 +1,15 @@
|
|||
# git.rootprojects.org/root/go-gitver v1.1.3
|
||||
## explicit
|
||||
git.rootprojects.org/root/go-gitver/gitver
|
||||
# git.rootprojects.org/root/go-gitver/v2 v2.0.1
|
||||
## explicit
|
||||
git.rootprojects.org/root/go-gitver/v2
|
||||
# git.rootprojects.org/root/keypairs v0.6.3
|
||||
## explicit
|
||||
git.rootprojects.org/root/keypairs
|
||||
git.rootprojects.org/root/keypairs/cmd/keypairs
|
||||
git.rootprojects.org/root/keypairs/keyfetch
|
||||
git.rootprojects.org/root/keypairs/keyfetch/uncached
|
||||
# github.com/go-chi/chi v4.1.2+incompatible
|
||||
## explicit
|
||||
github.com/go-chi/chi
|
||||
|
@ -22,7 +34,24 @@ github.com/shurcooL/httpfs/vfsutil
|
|||
## explicit
|
||||
github.com/shurcooL/vfsgen
|
||||
github.com/shurcooL/vfsgen/cmd/vfsgendev
|
||||
# golang.org/x/tools v0.0.0-20200925191224-5d1fdd8fa346
|
||||
# golang.org/x/mod v0.3.0
|
||||
golang.org/x/mod/semver
|
||||
# golang.org/x/tools v0.0.0-20201001230009-b5b87423c93b
|
||||
## explicit
|
||||
golang.org/x/tools/cmd/stringer
|
||||
golang.org/x/tools/go/gcexportdata
|
||||
golang.org/x/tools/go/internal/gcimporter
|
||||
golang.org/x/tools/go/internal/packagesdriver
|
||||
golang.org/x/tools/go/packages
|
||||
golang.org/x/tools/internal/event
|
||||
golang.org/x/tools/internal/event/core
|
||||
golang.org/x/tools/internal/event/keys
|
||||
golang.org/x/tools/internal/event/label
|
||||
golang.org/x/tools/internal/gocommand
|
||||
golang.org/x/tools/internal/packagesinternal
|
||||
golang.org/x/tools/internal/typesinternal
|
||||
# golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1
|
||||
golang.org/x/xerrors
|
||||
golang.org/x/xerrors/internal
|
||||
# google.golang.org/appengine v1.6.6
|
||||
## explicit
|
||||
|
|
Loading…
Reference in New Issue