Files
kanban/backend/modules_handlers.go
T
egutierrez c9e15513c7 chore: auto-commit (23 archivos)
- 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>
2026-05-21 18:22:44 +02:00

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