// 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) }