c9e15513c7
- app.md - backend/dist/assets/index-CFDWXN9Z.js - backend/dist/index.html - backend/handlers.go - backend/main.go - backend/users.go - e2e/smoke_live.sh - frontend/src/App.tsx - frontend/src/api.ts - frontend/src/components/CardChatPanel.tsx - ... Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
227 lines
6.2 KiB
Go
227 lines
6.2 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"fn-registry/functions/infra"
|
|
)
|
|
|
|
// requireAdmin gates a handler so only users with users.is_admin = 1 can
|
|
// reach it. Non-admins get a 403. Anonymous callers get a 401.
|
|
func requireAdmin(db *DB, next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
uid, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
|
|
if uid == "" {
|
|
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "session required"})
|
|
return
|
|
}
|
|
ok, err := db.IsAdmin(uid)
|
|
if err != nil {
|
|
serverError(w, err)
|
|
return
|
|
}
|
|
if !ok {
|
|
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusForbidden, Code: "forbidden", Message: "admin required"})
|
|
return
|
|
}
|
|
next(w, r)
|
|
}
|
|
}
|
|
|
|
// publicModule strips secrets out of the config before responding. The
|
|
// API token is never returned to the client after it has been stored.
|
|
func publicModule(m Module) Module {
|
|
clone := m
|
|
if clone.Config != nil {
|
|
cleaned := JSONValue{}
|
|
for k, v := range clone.Config {
|
|
if strings.Contains(strings.ToLower(k), "token") || strings.Contains(strings.ToLower(k), "password") || strings.Contains(strings.ToLower(k), "secret") {
|
|
cleaned[k] = "***"
|
|
} else {
|
|
cleaned[k] = v
|
|
}
|
|
}
|
|
clone.Config = cleaned
|
|
}
|
|
return clone
|
|
}
|
|
|
|
func handleListModules(db *DB) http.HandlerFunc {
|
|
return requireAdmin(db, func(w http.ResponseWriter, r *http.Request) {
|
|
mods, err := db.listModulesAll()
|
|
if err != nil {
|
|
serverError(w, err)
|
|
return
|
|
}
|
|
out := make([]Module, 0, len(mods))
|
|
for _, m := range mods {
|
|
out = append(out, publicModule(m))
|
|
}
|
|
infra.HTTPJSONResponse(w, http.StatusOK, out)
|
|
})
|
|
}
|
|
|
|
type modulePayload struct {
|
|
Name string `json:"name"`
|
|
Kind string `json:"kind"`
|
|
Enabled bool `json:"enabled"`
|
|
EventFilter []string `json:"event_filter"`
|
|
Config JSONValue `json:"config"`
|
|
}
|
|
|
|
func handleCreateModule(db *DB) http.HandlerFunc {
|
|
return requireAdmin(db, func(w http.ResponseWriter, r *http.Request) {
|
|
var body modulePayload
|
|
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
|
|
badRequest(w, err.Error())
|
|
return
|
|
}
|
|
if body.Name == "" || body.Kind == "" {
|
|
badRequest(w, "name and kind required")
|
|
return
|
|
}
|
|
m := &Module{
|
|
Name: body.Name, Kind: body.Kind, Enabled: body.Enabled,
|
|
EventFilter: body.EventFilter, Config: body.Config,
|
|
}
|
|
if err := db.saveModule(m); err != nil {
|
|
serverError(w, err)
|
|
return
|
|
}
|
|
infra.HTTPJSONResponse(w, http.StatusCreated, publicModule(*m))
|
|
})
|
|
}
|
|
|
|
func handleUpdateModule(db *DB) http.HandlerFunc {
|
|
return requireAdmin(db, func(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
existing, err := db.getModule(id)
|
|
if err != nil {
|
|
notFound(w, "module not found")
|
|
return
|
|
}
|
|
// Partial body: preserve fields the client did not include. We rely
|
|
// on a generic map to detect omitted vs explicit-null because PATCH
|
|
// callers do not always send the full record.
|
|
var raw map[string]json.RawMessage
|
|
if err := infra.HTTPParseBody(r, &raw, maxBodyBytes); err != nil {
|
|
badRequest(w, err.Error())
|
|
return
|
|
}
|
|
decode := func(key string, into interface{}) {
|
|
if v, ok := raw[key]; ok {
|
|
_ = json.Unmarshal(v, into)
|
|
}
|
|
}
|
|
decode("name", &existing.Name)
|
|
decode("kind", &existing.Kind)
|
|
decode("enabled", &existing.Enabled)
|
|
if v, ok := raw["event_filter"]; ok {
|
|
_ = json.Unmarshal(v, &existing.EventFilter)
|
|
}
|
|
if v, ok := raw["config"]; ok {
|
|
var cfg JSONValue
|
|
_ = json.Unmarshal(v, &cfg)
|
|
// Re-inject masked fields the UI left as "***" so a partial
|
|
// edit does not nuke stored secrets.
|
|
merged := JSONValue{}
|
|
for k, val := range existing.Config {
|
|
merged[k] = val
|
|
}
|
|
for k, val := range cfg {
|
|
if s, isStr := val.(string); isStr && s == "***" {
|
|
continue
|
|
}
|
|
merged[k] = val
|
|
}
|
|
existing.Config = merged
|
|
}
|
|
if err := db.saveModule(existing); err != nil {
|
|
serverError(w, err)
|
|
return
|
|
}
|
|
infra.HTTPJSONResponse(w, http.StatusOK, publicModule(*existing))
|
|
})
|
|
}
|
|
|
|
func handleDeleteModule(db *DB) http.HandlerFunc {
|
|
return requireAdmin(db, func(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
if err := db.deleteModule(id); err != nil {
|
|
serverError(w, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
})
|
|
}
|
|
|
|
func handleModuleLogs(db *DB) http.HandlerFunc {
|
|
return requireAdmin(db, func(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
limit := 100
|
|
if v := r.URL.Query().Get("limit"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 1000 {
|
|
limit = n
|
|
}
|
|
}
|
|
out, err := db.listModuleLogs(id, limit)
|
|
if err != nil {
|
|
serverError(w, err)
|
|
return
|
|
}
|
|
infra.HTTPJSONResponse(w, http.StatusOK, out)
|
|
})
|
|
}
|
|
|
|
// handleTestModule executes the kind-specific test_connection probe with
|
|
// the *current stored config* (or with an incoming config payload, for
|
|
// pre-save validation). Returns {ok, status, error} regardless of outcome
|
|
// so the UI can show a useful message.
|
|
func handleTestModule(db *DB, dispatcher *Dispatcher) http.HandlerFunc {
|
|
return requireAdmin(db, func(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
var m *Module
|
|
if id == "draft" {
|
|
// Pre-save test path: caller supplies a full module payload.
|
|
var body modulePayload
|
|
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
|
|
badRequest(w, err.Error())
|
|
return
|
|
}
|
|
m = &Module{Kind: body.Kind, Config: body.Config}
|
|
} else {
|
|
got, err := db.getModule(id)
|
|
if err != nil {
|
|
notFound(w, "module not found")
|
|
return
|
|
}
|
|
m = got
|
|
}
|
|
h, ok := dispatcher.handlers[m.Kind]
|
|
if !ok {
|
|
infra.HTTPJSONResponse(w, http.StatusOK, map[string]interface{}{
|
|
"ok": false, "status": 0, "error": "unknown kind: " + m.Kind,
|
|
})
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), moduleHTTPTimeout)
|
|
defer cancel()
|
|
start := time.Now()
|
|
status, err := h.TestConnection(ctx, *m)
|
|
resp := map[string]interface{}{
|
|
"ok": err == nil,
|
|
"status": status,
|
|
"duration_ms": int(time.Since(start).Milliseconds()),
|
|
}
|
|
if err != nil {
|
|
resp["error"] = err.Error()
|
|
}
|
|
infra.HTTPJSONResponse(w, http.StatusOK, resp)
|
|
})
|
|
}
|