Files
OwnCord/Server/api/plugins_handler.go
T
Claude 59ae4d8ad2 test+feat(phase-bc): pass 4 — test coverage, install endpoint, CHANGELOG
Final in-sandbox completeness pass. Five focused pieces; the remaining
items in PHASE_BC_LOCAL_TODO.md after this commit are all genuinely
local-only (toolchain, network, native deps).

Test coverage (the biggest gap from prior reviews)
- Server/plugin/manifest_test.go — pluginNameRegexp accept/reject table,
  validateRelativePath table, oversized version, unknown permission.
- Server/plugin/host_http_test.go — hostAllowed dot-boundary suffix,
  empty-entry rejection, case insensitivity, FQDN trailing dot. ipAllowed
  table over loopback, RFC1918, RFC4193 (ULA), RFC6598 (CGN), link-local,
  multicast, unspecified — both v4 and v6 — plus public-IP accept cases.
- Server/plugin/loader_test.go — rejectSymlinksUnder catches direct and
  nested symlinks; scanPluginDirectory rejects a plugin whose entrypoint
  is a symlink. Skipped on Windows where symlink creation needs elevation.
- Server/plugin/host_ui_test.go — AssetHandler serves declared files,
  rejects undeclared files (404), rejects path traversal, supports nested
  asset paths.
- Server/ws/hub_seedseq_test.go — SeedSeq monotonic, never-backwards,
  concurrent CAS safety, integration with nextSeq.
- Server/ws/extract_event_type_test.go — table covering happy paths,
  control char rejection, escaped quote rejection, length cap (64),
  empty/missing/non-JSON inputs.

Plugin install endpoint (closes a real feature gap)
- Server/plugin/registry.go — InstallFromZip extracts a plugin .zip into
  a staging directory under cfg.Directory, validates it zip-slip safe
  (cleaned-path Rel check), refuses non-regular entries, refuses
  symlinks, caps compressed at 16 MiB and uncompressed total at 64 MiB
  (each file gated by io.CopyN against the remaining budget). Manifest
  is parsed at the staged root, then atomically renamed into the
  canonical plugin directory and registered via the existing
  installFromDisk path.
- Server/api/plugins_handler.go — POST /install accepts multipart with
  one "plugin" file part, http.MaxBytesReader caps the request body,
  io.LimitReader caps the in-memory buffer, calls Registry.InstallFromZip,
  returns 201 with the new plugin name. The endpoint inherits the Pass 2
  admin auth + IP gate (mounted under r.Use(admin.RequireAdminAuth)).

Protocol surface
- Server/ws/serve.go — buildAuthOK now takes replaySource and includes
  it in the auth_ok payload as "replay_source": "none" | "buffer" | "db".
  Two call sites updated: reconnect path passes the existing local,
  fresh-connect path passes "none". Test export updated to pass "none".

CI build-tag matrix
- .github/workflows/ci.yml — three new steps inside server-build-test
  build the server with -tags otel, -tags wazero, and -tags otel,wazero.
  All three are continue-on-error: true until the upstream OTel and
  wazero modules land in go.mod (tracked in PHASE_BC_LOCAL_TODO.md).
  Once they do, dropping continue-on-error converts the steps into
  hard CI gates against tag-boundary drift.

Documentation
- CHANGELOG.md — new root-level file with curated entries for Phase B,
  Phase C, security, and behavioural changes operators must know about
  (notably event_persistence.enabled = true by default).
- PHASE_BC_LOCAL_TODO.md — ticks off the install endpoint, the
  replay_source field, and the existing event_persistence defaultYAML
  entry. The remaining items are toolchain-bound.

After this pass, the in-sandbox completeness ceiling is reached.
Everything still pending requires Go 1.25 toolchain, npm install,
real OTel SDK + wazero modules, sqlc, postgres backend impl, or
tinygo.

https://claude.ai/code/session_01UsBsQW2YiA2usk9pnJjAWk
2026-04-06 10:30:18 +00:00

156 lines
4.8 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"
"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, _, err := r.FormFile("plugin")
if err != nil {
http.Error(w, "missing 'plugin' file part", http.StatusBadRequest)
return
}
defer file.Close() //nolint:errcheck
// 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
}
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)
}
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
}