feat: ship Barkstack UI plugin
This commit is contained in:
3203
internal/adminui/dist/assets/entry.js
vendored
Normal file
3203
internal/adminui/dist/assets/entry.js
vendored
Normal file
File diff suppressed because one or more lines are too long
118
internal/adminui/handler.go
Normal file
118
internal/adminui/handler.go
Normal file
@@ -0,0 +1,118 @@
|
||||
// Package adminui serves PawSQL's Barkstack UI plugin and small read-only status API.
|
||||
package adminui
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed dist
|
||||
var embeddedUI embed.FS
|
||||
|
||||
const entryPath = "/barkstack/ui/assets/entry.js"
|
||||
|
||||
type manifest struct {
|
||||
APIVersion string `json:"apiVersion"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Icon string `json:"icon"`
|
||||
Mount string `json:"mount"`
|
||||
Entry string `json:"entry"`
|
||||
Element string `json:"element"`
|
||||
Navigation []navigationItem `json:"navigation"`
|
||||
}
|
||||
|
||||
type navigationItem struct {
|
||||
Label string `json:"label"`
|
||||
Path string `json:"path"`
|
||||
Icon string `json:"icon"`
|
||||
}
|
||||
|
||||
type RouteSummary struct {
|
||||
Name string `json:"name"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
Managed bool `json:"managed"`
|
||||
}
|
||||
|
||||
type statusResponse struct {
|
||||
Status string `json:"status"`
|
||||
Routes []RouteSummary `json:"routes"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
}
|
||||
|
||||
type handler struct {
|
||||
assets fs.FS
|
||||
routes []RouteSummary
|
||||
}
|
||||
|
||||
func Handler(routes []RouteSummary) http.Handler {
|
||||
assets, err := fs.Sub(embeddedUI, "dist")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
copyRoutes := append([]RouteSummary(nil), routes...)
|
||||
return &handler{assets: assets, routes: copyRoutes}
|
||||
}
|
||||
|
||||
func (h *handler) ServeHTTP(response http.ResponseWriter, request *http.Request) {
|
||||
if request.Method != http.MethodGet && request.Method != http.MethodHead {
|
||||
response.Header().Set("Allow", "GET, HEAD")
|
||||
http.Error(response, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
switch request.URL.Path {
|
||||
case "/barkstack/ui/manifest.json":
|
||||
h.serveJSON(response, request, manifest{
|
||||
APIVersion: "barkstack.dev/ui/v1", ID: "pawsql", Name: "PawSQL",
|
||||
Description: "PostgreSQL routing for Barkstack", Icon: "database", Mount: "/pawsql",
|
||||
Entry: entryPath, Element: "barkstack-pawsql",
|
||||
Navigation: []navigationItem{{Label: "PawSQL", Path: "/pawsql", Icon: "database"}},
|
||||
})
|
||||
case "/barkstack/ui/status.json":
|
||||
h.serveJSON(response, request, statusResponse{Status: "running", Routes: h.routes, Endpoint: "*.pawsql.barkstack.dev:5432"})
|
||||
default:
|
||||
h.serveAsset(response, request)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) serveJSON(response http.ResponseWriter, request *http.Request, value any) {
|
||||
response.Header().Set("Content-Type", "application/json")
|
||||
response.Header().Set("Cache-Control", "no-store")
|
||||
if request.Method == http.MethodHead {
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(response).Encode(value)
|
||||
}
|
||||
|
||||
func (h *handler) serveAsset(response http.ResponseWriter, request *http.Request) {
|
||||
const prefix = "/barkstack/ui/assets/"
|
||||
if !strings.HasPrefix(request.URL.Path, prefix) {
|
||||
http.NotFound(response, request)
|
||||
return
|
||||
}
|
||||
assetName := strings.TrimPrefix(request.URL.Path, "/barkstack/ui/")
|
||||
if assetName == "" || !fs.ValidPath(assetName) || path.Clean(assetName) != assetName || strings.Contains(assetName, "\\") {
|
||||
http.NotFound(response, request)
|
||||
return
|
||||
}
|
||||
contents, err := fs.ReadFile(h.assets, assetName)
|
||||
if err != nil {
|
||||
http.NotFound(response, request)
|
||||
return
|
||||
}
|
||||
if contentType := mime.TypeByExtension(path.Ext(assetName)); contentType != "" {
|
||||
response.Header().Set("Content-Type", contentType)
|
||||
}
|
||||
response.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
response.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
if request.Method == http.MethodHead {
|
||||
return
|
||||
}
|
||||
_, _ = response.Write(contents)
|
||||
}
|
||||
62
internal/adminui/handler_test.go
Normal file
62
internal/adminui/handler_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package adminui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestManifestEndpointDescribesPawSQLPlugin(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
Handler(nil).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/barkstack/ui/manifest.json", nil))
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", response.Code)
|
||||
}
|
||||
if got := response.Header().Get("Content-Type"); got != "application/json" {
|
||||
t.Fatalf("content type = %q", got)
|
||||
}
|
||||
var got manifest
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.APIVersion != "barkstack.dev/ui/v1" || got.ID != "pawsql" || got.Entry != entryPath || got.Element != "barkstack-pawsql" {
|
||||
t.Fatalf("manifest = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddedEntryAssetIsServedWithJavaScriptContentType(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
Handler(nil).ServeHTTP(response, httptest.NewRequest(http.MethodGet, entryPath, nil))
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", response.Code)
|
||||
}
|
||||
if got := response.Header().Get("Content-Type"); !strings.Contains(got, "javascript") {
|
||||
t.Fatalf("content type = %q", got)
|
||||
}
|
||||
if response.Body.Len() == 0 {
|
||||
t.Fatal("embedded entry asset is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusEndpointReportsConfiguredRoutes(t *testing.T) {
|
||||
routes := []RouteSummary{{Name: "reporting", Hostname: "reports.example.com", Managed: true}}
|
||||
response := httptest.NewRecorder()
|
||||
Handler(routes).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/barkstack/ui/status.json", nil))
|
||||
var got statusResponse
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Status != "running" || len(got.Routes) != 1 || got.Routes[0] != routes[0] {
|
||||
t.Fatalf("status response = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownAssetReturnsNotFound(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
Handler(nil).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/barkstack/ui/assets/missing.js", nil))
|
||||
if response.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", response.Code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user