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