Files
OwnCord/Server/api/plugins_handler.go
T
Claude 47d848ee0a feat(phase-bc): implement real OTel + Wazero runtimes; harden install path
Phase B Step 8 (OpenTelemetry) and Phase C Step 9 (Wazero plugin runtime)
were structurally scaffolded but the tagged builds were placeholders that
errored at runtime. This commit lands the real implementations behind the
existing build tags, plus three review passes worth of fixes across the
plugin admin handler, plugin registry, telemetry adapter, and Solid client.

Telemetry (Phase B Step 8)
- Add real go.opentelemetry.io/otel{,/sdk,/exporters/{prometheus,otlp...}}
  modules to go.mod plus contrib/instrumentation/net/http/otelhttp.
- Replace the telemetry_otel.go skeleton with a working Provider that
  wires Prometheus + OTLP/gRPC exporters, otelhttp middleware, span and
  meter adapters, and an idempotent Shutdown.
- AppMetrics cache is now reset *before* SetGlobal to close a race where
  a concurrent NewAppMetrics() could observe a swapped provider but read
  stale no-op instruments.
- Init releases the trace provider on a later prometheus exporter
  failure so Init never leaks gRPC connections.
- convertAttrs handles int32/uint/uint32/uint64/float32 explicitly;
  uint64 values that exceed math.MaxInt64 fall back to a STRING attr
  rather than wrapping into a negative int64 and corrupting metrics.
- Tests under -tags otel cover the prometheus scrape, span lifecycle,
  histogram recording, shutdown idempotency, AppMetrics rebind, and
  the uint64 overflow fallback.

Plugin runtime (Phase C Step 9)
- Add github.com/tetratelabs/wazero v1.11.0 to go.mod.
- platformInit creates a shared wazero.Runtime with WASI preview1
  pre-instantiated; activateWithRuntime compiles + instantiates each
  plugin module under that runtime; platformDeactivate closes per-
  plugin modules without tearing down the runtime.
- DisablePlugin now calls platformDeactivate so the wazero module is
  freed immediately instead of leaking until registry Close.
- activate() captures runtimePlatform under r.mu.RLock and passes it as
  a parameter to activateWithRuntime; the call no longer re-reads the
  field, closing a race with concurrent Close.
- invokeCommand calls the plugin's command_dispatch export when
  present; missing/broken exports return a user-facing diagnostic
  instead of crashing the dispatcher.
- Tests under -tags wazero cover registry creation, module compilation,
  re-enable after disable (verifies the leak fix), close-twice safety,
  invalid wasm rejection, and DispatchCommand with a missing export.
  Fixture is a 41-byte embedded add.wasm; no external asset required.

Plugin admin handler hardening
- /api/v1/admin/plugins/install now rejects uploads whose multipart
  Content-Type is not application/zip|x-zip-compressed|octet-stream
  (415) and uploads whose body lacks the PK\\x03\\x04 / PK\\x05\\x06
  zip magic (400). The 16 MiB cap and registry-side zip-slip / symlink
  / size-bomb defences are still applied as before.
- New plugins_handler_test.go covers list-empty, install-503-when-nil,
  content-type rejection, magic rejection, happy path, lifecycle 503,
  invalid id, and isZipContentType / hasZipMagic helpers.

Solid client (Phase B Step 6) cleanup
- vitest.config.ts now wires vite-plugin-solid and broadens the test
  glob to include src/**/*.test.tsx so Badge.test.tsx is actually
  discovered (it was silently skipped).
- pluginBridge.ts targets postMessage at window.location.origin
  instead of "*", and exposes a destroy() that detaches the message
  listener and clears mounted frames.
- solidMount.ts imports the JSX type from "solid-js" instead of
  "solid-js/web" (the latter does not re-export it), unblocking
  npx tsc --noEmit.

Build/test status
- go build succeeds on default, -tags otel, -tags wazero, and
  -tags otel,wazero.
- go test passes on every tag combination across telemetry, plugin,
  api, ws, service, store, and the rest of the tree.
- Client: npx tsc --noEmit clean; vitest 3188/3188 across 112 files.

PHASE_BC_LOCAL_TODO.md is updated to mark the OTel modules + real Init,
the wazero module + real platformInit, and the test coverage that
landed in this commit as completed.

https://claude.ai/code/session_01AZni6CDSQeu67WSWY1YCDX
2026-04-06 21:46:22 +00:00

207 lines
6.6 KiB
Go

// Phase C Step 9 — Plugin admin REST surface.
//
// All endpoints are mounted under the existing AdminIPRestrict group so they
// inherit the same network ACL as the rest of the admin panel. Authentication
// is handled by the admin handler's middleware before this handler runs.
package api
import (
"io"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/owncord/server/plugin"
"github.com/owncord/server/store"
)
// maxPluginUploadBytes caps the multipart upload at 16 MiB to match the
// plugin.maxZipBytes ceiling. The handler enforces both layers because the
// outer MaxBytesReader gives a clean 413 instead of a partial extract.
const maxPluginUploadBytes = 16 * 1024 * 1024
// PluginAdminHandler exposes plugin lifecycle operations to the admin panel.
type PluginAdminHandler struct {
registry *plugin.Registry
store store.PluginStore
}
// NewPluginAdminHandler builds an http.Handler that the router can mount.
// Pass a nil registry when plugin support is disabled — the handler then
// reports an empty list and 503 on lifecycle calls.
func NewPluginAdminHandler(registry *plugin.Registry, st store.PluginStore) http.Handler {
h := &PluginAdminHandler{registry: registry, store: st}
r := chi.NewRouter()
r.Get("/", h.list)
r.Post("/install", h.install)
r.Post("/{id}/enable", h.enable)
r.Post("/{id}/disable", h.disable)
r.Delete("/{id}", h.uninstall)
return r
}
// install accepts a multipart upload with a single "plugin" file part
// containing a .zip. The zip is validated (zip-slip safe, no symlinks,
// uncompressed total capped, manifest required at root) and installed via
// Registry.InstallFromZip. Returns 201 with the new plugin name on success.
func (h *PluginAdminHandler) install(w http.ResponseWriter, r *http.Request) {
if h.registry == nil {
http.Error(w, "plugin runtime disabled", http.StatusServiceUnavailable)
return
}
// Hard cap on the request body before parsing multipart so a hostile
// client can't tie up parsing memory.
r.Body = http.MaxBytesReader(w, r.Body, maxPluginUploadBytes+1024)
if err := r.ParseMultipartForm(maxPluginUploadBytes); err != nil {
http.Error(w, "invalid multipart upload: "+err.Error(), http.StatusBadRequest)
return
}
file, header, err := r.FormFile("plugin")
if err != nil {
http.Error(w, "missing 'plugin' file part", http.StatusBadRequest)
return
}
defer file.Close() //nolint:errcheck
// Reject obviously-wrong uploads early. The real defence is the zip
// reader inside InstallFromZip (content-type is client-supplied and must
// never be trusted for authorisation), but rejecting non-zip MIME types
// here returns a cleaner 400 than a "not a valid zip" error from deep
// inside the registry.
if header != nil {
if ct := header.Header.Get("Content-Type"); ct != "" && !isZipContentType(ct) {
http.Error(w, "plugin upload must be a .zip archive", http.StatusUnsupportedMediaType)
return
}
}
// Read the entire zip into memory — InstallFromZip needs an io.ReaderAt
// for archive/zip and the cap is small enough to be safe.
body, err := io.ReadAll(io.LimitReader(file, maxPluginUploadBytes+1))
if err != nil {
http.Error(w, "read upload: "+err.Error(), http.StatusBadRequest)
return
}
if int64(len(body)) > maxPluginUploadBytes {
http.Error(w, "plugin upload too large", http.StatusRequestEntityTooLarge)
return
}
// Magic-byte check: a real .zip starts with "PK\x03\x04" (local file
// header) or "PK\x05\x06" (empty archive). Anything else is definitively
// not a zip regardless of what the client labelled it.
if !hasZipMagic(body) {
http.Error(w, "plugin upload is not a valid zip archive", http.StatusBadRequest)
return
}
name, err := h.registry.InstallFromZip(r.Context(), body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
writeJSON(w, http.StatusCreated, map[string]any{"name": name})
}
func (h *PluginAdminHandler) list(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if h.store == nil {
writeJSON(w, http.StatusOK, []any{})
return
}
rows, err := h.store.ListPlugins(ctx)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, rows)
}
func (h *PluginAdminHandler) enable(w http.ResponseWriter, r *http.Request) {
id, ok := parsePluginID(w, r)
if !ok {
return
}
if h.registry == nil {
http.Error(w, "plugin runtime disabled", http.StatusServiceUnavailable)
return
}
if err := h.registry.EnablePlugin(r.Context(), id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *PluginAdminHandler) disable(w http.ResponseWriter, r *http.Request) {
id, ok := parsePluginID(w, r)
if !ok {
return
}
if h.registry == nil {
http.Error(w, "plugin runtime disabled", http.StatusServiceUnavailable)
return
}
if err := h.registry.DisablePlugin(r.Context(), id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *PluginAdminHandler) uninstall(w http.ResponseWriter, r *http.Request) {
id, ok := parsePluginID(w, r)
if !ok {
return
}
if h.registry == nil {
http.Error(w, "plugin runtime disabled", http.StatusServiceUnavailable)
return
}
if err := h.registry.UninstallPlugin(r.Context(), id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// isZipContentType reports whether ct looks like a zip MIME type. Both the
// IANA-registered application/zip and the legacy application/x-zip-compressed
// (used by some Windows clients) are accepted. The comparison is case-
// insensitive and strips any parameters after a semicolon.
func isZipContentType(ct string) bool {
for i := 0; i < len(ct); i++ {
if ct[i] == ';' {
ct = ct[:i]
break
}
}
switch strings.ToLower(strings.TrimSpace(ct)) {
case "application/zip", "application/x-zip-compressed", "application/octet-stream":
return true
}
return false
}
// hasZipMagic reports whether b begins with the PK signature used by every
// .zip archive. Empty archives use 0x50,0x4b,0x05,0x06; non-empty archives
// start with a local file header 0x50,0x4b,0x03,0x04. Both are accepted.
func hasZipMagic(b []byte) bool {
if len(b) < 4 {
return false
}
if b[0] != 'P' || b[1] != 'K' {
return false
}
return (b[2] == 0x03 && b[3] == 0x04) || (b[2] == 0x05 && b[3] == 0x06)
}
func parsePluginID(w http.ResponseWriter, r *http.Request) (int64, bool) {
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || id <= 0 {
http.Error(w, "invalid plugin id", http.StatusBadRequest)
return 0, false
}
return id, true
}