Files
cc-connect/core/api.go
T
Claude 2b2210829b feat(attachments): add size limit and path sanitization
Added max attachment size limit (50MB) with validation. Implemented path sanitization for attachments. Added related tests for size limit and path cleaning. Also added i18n messages for BTW command responses and improved connection cleanup in WeCom websocket.

generated by llmgit

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-18 11:50:18 +08:00

416 lines
11 KiB
Go

package core
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"os"
"path/filepath"
"sync"
"time"
)
// APIServer exposes a local Unix socket API for external tools (e.g. cron jobs)
// to send messages to active sessions.
type APIServer struct {
socketPath string
listener net.Listener
server *http.Server
mux *http.ServeMux
engines map[string]*Engine // project name → engine
cron *CronScheduler
relay *RelayManager
mu sync.RWMutex
}
// SendRequest is the JSON body for POST /send.
type SendRequest struct {
Project string `json:"project"`
SessionKey string `json:"session_key"`
Message string `json:"message"`
Images []ImageAttachment `json:"images,omitempty"`
Files []FileAttachment `json:"files,omitempty"`
}
// NewAPIServer creates an API server on a Unix socket.
func NewAPIServer(dataDir string) (*APIServer, error) {
sockDir := filepath.Join(dataDir, "run")
if err := os.MkdirAll(sockDir, 0o755); err != nil {
return nil, fmt.Errorf("create run dir: %w", err)
}
sockPath := filepath.Join(sockDir, "api.sock")
// Remove stale socket
os.Remove(sockPath)
listener, err := net.Listen("unix", sockPath)
if err != nil {
return nil, fmt.Errorf("listen unix socket: %w", err)
}
os.Chmod(sockPath, 0o600)
s := &APIServer{
socketPath: sockPath,
listener: listener,
mux: http.NewServeMux(),
engines: make(map[string]*Engine),
}
s.mux.HandleFunc("/send", s.handleSend)
s.mux.HandleFunc("/sessions", s.handleSessions)
s.mux.HandleFunc("/cron/add", s.handleCronAdd)
s.mux.HandleFunc("/cron/list", s.handleCronList)
s.mux.HandleFunc("/cron/del", s.handleCronDel)
s.mux.HandleFunc("/relay/send", s.handleRelaySend)
s.mux.HandleFunc("/relay/bind", s.handleRelayBind)
s.mux.HandleFunc("/relay/binding", s.handleRelayBinding)
return s, nil
}
func (s *APIServer) SocketPath() string {
return s.socketPath
}
func (s *APIServer) RegisterEngine(name string, e *Engine) {
s.mu.Lock()
defer s.mu.Unlock()
s.engines[name] = e
if s.relay != nil {
s.relay.RegisterEngine(name, e)
}
}
func (s *APIServer) SetRelayManager(rm *RelayManager) {
s.relay = rm
}
func (s *APIServer) RelayManager() *RelayManager {
return s.relay
}
func (s *APIServer) SetCronScheduler(cs *CronScheduler) {
s.cron = cs
}
func (s *APIServer) Start() {
s.server = &http.Server{Handler: s.mux}
go func() {
if err := s.server.Serve(s.listener); err != nil && err != http.ErrServerClosed {
slog.Error("api server error", "error", err)
}
}()
slog.Info("api server started", "socket", s.socketPath)
}
func (s *APIServer) Stop() {
s.server.Close()
os.Remove(s.socketPath)
}
func (s *APIServer) handleSend(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
const maxSendBody = 52 << 20 // 52 MB (slightly above max attachment to account for base64 overhead)
var req SendRequest
if err := json.NewDecoder(io.LimitReader(r.Body, maxSendBody)).Decode(&req); err != nil {
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
return
}
if req.Message == "" && len(req.Images) == 0 && len(req.Files) == 0 {
http.Error(w, "message or attachment is required", http.StatusBadRequest)
return
}
s.mu.RLock()
engine, ok := s.engines[req.Project]
s.mu.RUnlock()
if !ok {
// If only one engine, use it by default
s.mu.RLock()
if len(s.engines) == 1 {
for _, e := range s.engines {
engine = e
ok = true
}
}
s.mu.RUnlock()
}
if !ok {
http.Error(w, fmt.Sprintf("project %q not found", req.Project), http.StatusNotFound)
return
}
if err := engine.SendToSessionWithAttachments(req.SessionKey, req.Message, req.Images, req.Files); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func (s *APIServer) handleSessions(w http.ResponseWriter, r *http.Request) {
s.mu.RLock()
defer s.mu.RUnlock()
type sessionInfo struct {
Project string `json:"project"`
SessionKey string `json:"session_key"`
Platform string `json:"platform"`
}
var result []sessionInfo
for name, e := range s.engines {
e.interactiveMu.Lock()
for key, state := range e.interactiveStates {
if state.platform != nil {
result = append(result, sessionInfo{
Project: name,
SessionKey: key,
Platform: state.platform.Name(),
})
}
}
e.interactiveMu.Unlock()
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
}
// ── Cron API ───────────────────────────────────────────────────
// CronAddRequest is the JSON body for POST /cron/add.
type CronAddRequest struct {
Project string `json:"project"`
SessionKey string `json:"session_key"`
CronExpr string `json:"cron_expr"`
Prompt string `json:"prompt"`
Exec string `json:"exec"`
WorkDir string `json:"work_dir"`
Description string `json:"description"`
Silent *bool `json:"silent,omitempty"`
}
func (s *APIServer) handleCronAdd(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
if s.cron == nil {
http.Error(w, "cron scheduler not available", http.StatusServiceUnavailable)
return
}
var req CronAddRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
return
}
if req.CronExpr == "" {
http.Error(w, "cron_expr is required", http.StatusBadRequest)
return
}
if req.Prompt == "" && req.Exec == "" {
http.Error(w, "either prompt or exec is required", http.StatusBadRequest)
return
}
if req.Prompt != "" && req.Exec != "" {
http.Error(w, "prompt and exec are mutually exclusive", http.StatusBadRequest)
return
}
// Resolve project: use provided, or pick single engine
project := req.Project
if project == "" {
s.mu.RLock()
if len(s.engines) == 1 {
for name := range s.engines {
project = name
}
}
s.mu.RUnlock()
}
if project == "" {
http.Error(w, "project is required (multiple projects configured)", http.StatusBadRequest)
return
}
// Resolve session_key: use provided, or auto-detect from active sessions
sessionKey := req.SessionKey
if sessionKey == "" {
s.mu.RLock()
engine := s.engines[project]
s.mu.RUnlock()
if engine != nil {
keys := engine.ActiveSessionKeys()
if len(keys) == 1 {
sessionKey = keys[0]
slog.Debug("auto-detected session_key for cron job", "session_key", sessionKey)
}
}
}
if sessionKey == "" {
http.Error(w, "session_key is required: set CC_SESSION_KEY env, pass --session-key, or ensure exactly one active session exists", http.StatusBadRequest)
return
}
job := &CronJob{
ID: GenerateCronID(),
Project: project,
SessionKey: sessionKey,
CronExpr: req.CronExpr,
Prompt: req.Prompt,
Exec: req.Exec,
WorkDir: req.WorkDir,
Description: req.Description,
Enabled: true,
Silent: req.Silent,
}
job.CreatedAt = time.Now()
if err := s.cron.AddJob(job); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(job)
}
func (s *APIServer) handleCronList(w http.ResponseWriter, r *http.Request) {
if s.cron == nil {
http.Error(w, "cron scheduler not available", http.StatusServiceUnavailable)
return
}
project := r.URL.Query().Get("project")
var jobs []*CronJob
if project != "" {
jobs = s.cron.Store().ListByProject(project)
} else {
jobs = s.cron.Store().List()
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(jobs)
}
func (s *APIServer) handleCronDel(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
if s.cron == nil {
http.Error(w, "cron scheduler not available", http.StatusServiceUnavailable)
return
}
var req struct {
ID string `json:"id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
return
}
if req.ID == "" {
http.Error(w, "id is required", http.StatusBadRequest)
return
}
if s.cron.RemoveJob(req.ID) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
} else {
http.Error(w, fmt.Sprintf("job %q not found", req.ID), http.StatusNotFound)
}
}
// ── Relay API ──────────────────────────────────────────────────
func (s *APIServer) handleRelaySend(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
if s.relay == nil {
http.Error(w, "relay not available", http.StatusServiceUnavailable)
return
}
var req RelayRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
return
}
if req.To == "" || req.Message == "" || req.SessionKey == "" {
http.Error(w, "to, session_key, and message are required", http.StatusBadRequest)
return
}
resp, err := s.relay.Send(r.Context(), req)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
func (s *APIServer) handleRelayBind(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
if s.relay == nil {
http.Error(w, "relay not available", http.StatusServiceUnavailable)
return
}
var req struct {
Platform string `json:"platform"`
ChatID string `json:"chat_id"`
Bots map[string]string `json:"bots"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
return
}
if req.ChatID == "" || len(req.Bots) < 2 {
http.Error(w, "chat_id and at least 2 bots are required", http.StatusBadRequest)
return
}
s.relay.Bind(req.Platform, req.ChatID, req.Bots)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func (s *APIServer) handleRelayBinding(w http.ResponseWriter, r *http.Request) {
if s.relay == nil {
http.Error(w, "relay not available", http.StatusServiceUnavailable)
return
}
chatID := r.URL.Query().Get("chat_id")
if chatID == "" {
http.Error(w, "chat_id is required", http.StatusBadRequest)
return
}
binding := s.relay.GetBinding(chatID)
if binding == nil {
http.Error(w, "no binding found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(binding)
}