mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-17 12:48:37 +00:00
a7f5acc1e3
- execMapUpdate: validate column names with strict regex to prevent SQL injection - HTTP update handlers: add field allowlists (agents, providers, custom_tools, mcp, channel_instances) - pqStringArray: properly escape array elements to prevent PostgreSQL array literal injection - scanStringArray: handle quoted elements in PostgreSQL array format - MCP bridge: wrap tool results as external/untrusted content to prevent prompt injection - File serving: block access to sensitive system directories (/etc, /proc, /sys, etc.) - Sandbox: fail closed when Docker unavailable instead of silent fallback to host - Shell deny: fix base64 --decode bypass, add host exec 1MB output limit - ILIKE queries: escape % and _ wildcards in knowledge_graph, custom_tools, channel_instances Co-authored-by: Luvu182 <208665161+Luvu182@users.noreply.github.com>
473 lines
16 KiB
Go
473 lines
16 KiB
Go
package http
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
|
)
|
|
|
|
// ChannelInstancesHandler handles channel instance CRUD endpoints.
|
|
type ChannelInstancesHandler struct {
|
|
store store.ChannelInstanceStore
|
|
agentStore store.AgentStore
|
|
contactStore store.ContactStore
|
|
token string
|
|
msgBus *bus.MessageBus
|
|
}
|
|
|
|
// NewChannelInstancesHandler creates a handler for channel instance management endpoints.
|
|
func NewChannelInstancesHandler(s store.ChannelInstanceStore, agentStore store.AgentStore, contactStore store.ContactStore, token string, msgBus *bus.MessageBus) *ChannelInstancesHandler {
|
|
return &ChannelInstancesHandler{store: s, agentStore: agentStore, contactStore: contactStore, token: token, msgBus: msgBus}
|
|
}
|
|
|
|
// RegisterRoutes registers all channel instance routes on the given mux.
|
|
func (h *ChannelInstancesHandler) RegisterRoutes(mux *http.ServeMux) {
|
|
mux.HandleFunc("GET /v1/channels/instances", h.auth(h.handleList))
|
|
mux.HandleFunc("POST /v1/channels/instances", h.auth(h.handleCreate))
|
|
mux.HandleFunc("GET /v1/channels/instances/{id}", h.auth(h.handleGet))
|
|
mux.HandleFunc("PUT /v1/channels/instances/{id}", h.auth(h.handleUpdate))
|
|
mux.HandleFunc("DELETE /v1/channels/instances/{id}", h.auth(h.handleDelete))
|
|
|
|
// Channel contacts (global, not per-agent)
|
|
if h.contactStore != nil {
|
|
mux.HandleFunc("GET /v1/contacts", h.auth(h.handleListContacts))
|
|
mux.HandleFunc("GET /v1/contacts/resolve", h.auth(h.handleResolveContacts))
|
|
}
|
|
|
|
// Group file writers (nested under channel instances)
|
|
if h.agentStore != nil {
|
|
mux.HandleFunc("GET /v1/channels/instances/{id}/writers/groups", h.auth(h.handleWriterGroups))
|
|
mux.HandleFunc("GET /v1/channels/instances/{id}/writers", h.auth(h.handleListWriters))
|
|
mux.HandleFunc("POST /v1/channels/instances/{id}/writers", h.auth(h.handleAddWriter))
|
|
mux.HandleFunc("DELETE /v1/channels/instances/{id}/writers/{userId}", h.auth(h.handleRemoveWriter))
|
|
}
|
|
}
|
|
|
|
func (h *ChannelInstancesHandler) auth(next http.HandlerFunc) http.HandlerFunc {
|
|
return requireAuth(h.token, "", next)
|
|
}
|
|
|
|
func (h *ChannelInstancesHandler) emitCacheInvalidate() {
|
|
if h.msgBus == nil {
|
|
return
|
|
}
|
|
h.msgBus.Broadcast(bus.Event{
|
|
Name: protocol.EventCacheInvalidate,
|
|
Payload: bus.CacheInvalidatePayload{Kind: bus.CacheKindChannelInstances},
|
|
})
|
|
}
|
|
|
|
func (h *ChannelInstancesHandler) handleList(w http.ResponseWriter, r *http.Request) {
|
|
opts := store.ChannelInstanceListOpts{
|
|
Limit: 50,
|
|
Offset: 0,
|
|
}
|
|
|
|
if v := r.URL.Query().Get("search"); v != "" {
|
|
opts.Search = v
|
|
}
|
|
if v := r.URL.Query().Get("limit"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 {
|
|
opts.Limit = n
|
|
}
|
|
}
|
|
if v := r.URL.Query().Get("offset"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
|
opts.Offset = n
|
|
}
|
|
}
|
|
|
|
instances, err := h.store.ListPaged(r.Context(), opts)
|
|
if err != nil {
|
|
slog.Error("channel_instances.list", "error", err)
|
|
locale := store.LocaleFromContext(r.Context())
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "instances")})
|
|
return
|
|
}
|
|
|
|
total, _ := h.store.CountInstances(r.Context(), opts)
|
|
|
|
result := make([]map[string]any, 0, len(instances))
|
|
for _, inst := range instances {
|
|
result = append(result, maskInstanceHTTP(inst))
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"instances": result,
|
|
"total": total,
|
|
"limit": opts.Limit,
|
|
"offset": opts.Offset,
|
|
})
|
|
}
|
|
|
|
func (h *ChannelInstancesHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
|
|
locale := store.LocaleFromContext(r.Context())
|
|
var body struct {
|
|
Name string `json:"name"`
|
|
DisplayName string `json:"display_name"`
|
|
ChannelType string `json:"channel_type"`
|
|
AgentID string `json:"agent_id"`
|
|
Credentials json.RawMessage `json:"credentials"`
|
|
Config json.RawMessage `json:"config"`
|
|
Enabled *bool `json:"enabled"`
|
|
}
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
|
|
return
|
|
}
|
|
|
|
if body.Name == "" || body.ChannelType == "" || body.AgentID == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "name, channel_type, and agent_id")})
|
|
return
|
|
}
|
|
|
|
if !isValidChannelType(body.ChannelType) {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidChannelType)})
|
|
return
|
|
}
|
|
|
|
agentID, err := uuid.Parse(body.AgentID)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
|
|
return
|
|
}
|
|
|
|
enabled := true
|
|
if body.Enabled != nil {
|
|
enabled = *body.Enabled
|
|
}
|
|
|
|
userID := store.UserIDFromContext(r.Context())
|
|
|
|
inst := &store.ChannelInstanceData{
|
|
Name: body.Name,
|
|
DisplayName: body.DisplayName,
|
|
ChannelType: body.ChannelType,
|
|
AgentID: agentID,
|
|
Credentials: body.Credentials,
|
|
Config: body.Config,
|
|
Enabled: enabled,
|
|
CreatedBy: userID,
|
|
}
|
|
|
|
if err := h.store.Create(r.Context(), inst); err != nil {
|
|
slog.Error("channel_instances.create", "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
h.emitCacheInvalidate()
|
|
emitAudit(h.msgBus, r, "channel_instance.created", "channel_instance", inst.ID.String())
|
|
writeJSON(w, http.StatusCreated, maskInstanceHTTP(*inst))
|
|
}
|
|
|
|
func (h *ChannelInstancesHandler) handleGet(w http.ResponseWriter, r *http.Request) {
|
|
locale := store.LocaleFromContext(r.Context())
|
|
id, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "instance")})
|
|
return
|
|
}
|
|
|
|
inst, err := h.store.Get(r.Context(), id)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgInstanceNotFound)})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, maskInstanceHTTP(*inst))
|
|
}
|
|
|
|
func (h *ChannelInstancesHandler) handleUpdate(w http.ResponseWriter, r *http.Request) {
|
|
locale := store.LocaleFromContext(r.Context())
|
|
id, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "instance")})
|
|
return
|
|
}
|
|
|
|
var updates map[string]any
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&updates); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
|
|
return
|
|
}
|
|
|
|
// Allowlist: only permit known channel instance columns.
|
|
updates = filterAllowedKeys(updates, channelInstanceAllowedFields)
|
|
|
|
if err := h.store.Update(r.Context(), id, updates); err != nil {
|
|
slog.Error("channel_instances.update", "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
h.emitCacheInvalidate()
|
|
emitAudit(h.msgBus, r, "channel_instance.updated", "channel_instance", id.String())
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
|
|
}
|
|
|
|
func (h *ChannelInstancesHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
|
|
locale := store.LocaleFromContext(r.Context())
|
|
id, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "instance")})
|
|
return
|
|
}
|
|
|
|
// Look up instance to check if it's a default (seeded) instance.
|
|
inst, err := h.store.Get(r.Context(), id)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgInstanceNotFound)})
|
|
return
|
|
}
|
|
if store.IsDefaultChannelInstance(inst.Name) {
|
|
writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgCannotDeleteDefaultInst)})
|
|
return
|
|
}
|
|
|
|
if err := h.store.Delete(r.Context(), id); err != nil {
|
|
slog.Error("channel_instances.delete", "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
h.emitCacheInvalidate()
|
|
emitAudit(h.msgBus, r, "channel_instance.deleted", "channel_instance", id.String())
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
|
}
|
|
|
|
// maskInstanceHTTP returns a map with credentials masked for HTTP responses.
|
|
func maskInstanceHTTP(inst store.ChannelInstanceData) map[string]any {
|
|
result := map[string]any{
|
|
"id": inst.ID,
|
|
"name": inst.Name,
|
|
"display_name": inst.DisplayName,
|
|
"channel_type": inst.ChannelType,
|
|
"agent_id": inst.AgentID,
|
|
"config": inst.Config,
|
|
"enabled": inst.Enabled,
|
|
"is_default": store.IsDefaultChannelInstance(inst.Name),
|
|
"has_credentials": len(inst.Credentials) > 0,
|
|
"created_by": inst.CreatedBy,
|
|
"created_at": inst.CreatedAt,
|
|
"updated_at": inst.UpdatedAt,
|
|
}
|
|
|
|
if len(inst.Credentials) > 0 {
|
|
var raw map[string]any
|
|
if json.Unmarshal(inst.Credentials, &raw) == nil {
|
|
masked := make(map[string]any, len(raw))
|
|
for k := range raw {
|
|
masked[k] = "***"
|
|
}
|
|
result["credentials"] = masked
|
|
} else {
|
|
result["credentials"] = map[string]string{}
|
|
}
|
|
} else {
|
|
result["credentials"] = map[string]string{}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// --- Group file writers ---
|
|
|
|
// resolveAgentID looks up the channel instance and returns its agent_id.
|
|
func (h *ChannelInstancesHandler) resolveAgentID(w http.ResponseWriter, r *http.Request) (uuid.UUID, bool) {
|
|
locale := store.LocaleFromContext(r.Context())
|
|
instID, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "instance")})
|
|
return uuid.Nil, false
|
|
}
|
|
inst, err := h.store.Get(r.Context(), instID)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgInstanceNotFound)})
|
|
return uuid.Nil, false
|
|
}
|
|
return inst.AgentID, true
|
|
}
|
|
|
|
func (h *ChannelInstancesHandler) handleWriterGroups(w http.ResponseWriter, r *http.Request) {
|
|
agentID, ok := h.resolveAgentID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
groups, err := h.agentStore.ListGroupFileWriterGroups(r.Context(), agentID)
|
|
if err != nil {
|
|
slog.Error("channel_instances.writer_groups", "error", err)
|
|
locale := store.LocaleFromContext(r.Context())
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "writer groups")})
|
|
return
|
|
}
|
|
if groups == nil {
|
|
groups = []store.GroupWriterGroupInfo{}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"groups": groups})
|
|
}
|
|
|
|
func (h *ChannelInstancesHandler) handleListWriters(w http.ResponseWriter, r *http.Request) {
|
|
agentID, ok := h.resolveAgentID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
locale := store.LocaleFromContext(r.Context())
|
|
groupID := r.URL.Query().Get("group_id")
|
|
if groupID == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "group_id")})
|
|
return
|
|
}
|
|
writers, err := h.agentStore.ListGroupFileWriters(r.Context(), agentID, groupID)
|
|
if err != nil {
|
|
slog.Error("channel_instances.list_writers", "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "writers")})
|
|
return
|
|
}
|
|
if writers == nil {
|
|
writers = []store.GroupFileWriterData{}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"writers": writers})
|
|
}
|
|
|
|
func (h *ChannelInstancesHandler) handleAddWriter(w http.ResponseWriter, r *http.Request) {
|
|
agentID, ok := h.resolveAgentID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
locale := store.LocaleFromContext(r.Context())
|
|
var body struct {
|
|
GroupID string `json:"group_id"`
|
|
UserID string `json:"user_id"`
|
|
DisplayName string `json:"display_name"`
|
|
Username string `json:"username"`
|
|
}
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&body); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
|
|
return
|
|
}
|
|
if body.GroupID == "" || body.UserID == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "group_id and user_id")})
|
|
return
|
|
}
|
|
if err := h.agentStore.AddGroupFileWriter(r.Context(), agentID, body.GroupID, body.UserID, body.DisplayName, body.Username); err != nil {
|
|
slog.Error("channel_instances.add_writer", "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToCreate, "writer", err.Error())})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, map[string]string{"status": "added"})
|
|
}
|
|
|
|
func (h *ChannelInstancesHandler) handleRemoveWriter(w http.ResponseWriter, r *http.Request) {
|
|
agentID, ok := h.resolveAgentID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
locale := store.LocaleFromContext(r.Context())
|
|
userID := r.PathValue("userId")
|
|
groupID := r.URL.Query().Get("group_id")
|
|
if groupID == "" || userID == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "group_id and userId")})
|
|
return
|
|
}
|
|
if err := h.agentStore.RemoveGroupFileWriter(r.Context(), agentID, groupID, userID); err != nil {
|
|
slog.Error("channel_instances.remove_writer", "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToDelete, "writer", err.Error())})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "removed"})
|
|
}
|
|
|
|
// --- Channel contacts ---
|
|
|
|
func (h *ChannelInstancesHandler) handleListContacts(w http.ResponseWriter, r *http.Request) {
|
|
opts := store.ContactListOpts{
|
|
Limit: 50,
|
|
Offset: 0,
|
|
}
|
|
|
|
if v := r.URL.Query().Get("search"); v != "" {
|
|
opts.Search = v
|
|
}
|
|
if v := r.URL.Query().Get("channel_type"); v != "" {
|
|
opts.ChannelType = v
|
|
}
|
|
if v := r.URL.Query().Get("peer_kind"); v != "" {
|
|
opts.PeerKind = v
|
|
}
|
|
if v := r.URL.Query().Get("limit"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 {
|
|
opts.Limit = n
|
|
}
|
|
}
|
|
if v := r.URL.Query().Get("offset"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
|
opts.Offset = n
|
|
}
|
|
}
|
|
|
|
contacts, err := h.contactStore.ListContacts(r.Context(), opts)
|
|
if err != nil {
|
|
slog.Error("contacts.list", "error", err)
|
|
locale := store.LocaleFromContext(r.Context())
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "contacts")})
|
|
return
|
|
}
|
|
if contacts == nil {
|
|
contacts = []store.ChannelContact{}
|
|
}
|
|
|
|
total, countErr := h.contactStore.CountContacts(r.Context(), opts)
|
|
if countErr != nil {
|
|
slog.Warn("contacts.count", "error", countErr)
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"contacts": contacts,
|
|
"total": total,
|
|
"limit": opts.Limit,
|
|
"offset": opts.Offset,
|
|
})
|
|
}
|
|
|
|
func (h *ChannelInstancesHandler) handleResolveContacts(w http.ResponseWriter, r *http.Request) {
|
|
idsParam := r.URL.Query().Get("ids")
|
|
if idsParam == "" {
|
|
writeJSON(w, http.StatusOK, map[string]any{"contacts": map[string]any{}})
|
|
return
|
|
}
|
|
|
|
ids := strings.Split(idsParam, ",")
|
|
if len(ids) > 100 {
|
|
ids = ids[:100]
|
|
}
|
|
|
|
result, err := h.contactStore.GetContactsBySenderIDs(r.Context(), ids)
|
|
if err != nil {
|
|
slog.Error("contacts.resolve", "error", err)
|
|
locale := store.LocaleFromContext(r.Context())
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "contacts")})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"contacts": result})
|
|
}
|
|
|
|
// isValidChannelType checks if the channel type is supported.
|
|
func isValidChannelType(ct string) bool {
|
|
switch ct {
|
|
case "telegram", "discord", "slack", "whatsapp", "zalo_oa", "zalo_personal", "feishu":
|
|
return true
|
|
}
|
|
return false
|
|
}
|