mirror of
https://github.com/anotherhadi/iknowyou.git
synced 2026-04-11 16:37:25 +02:00
594 lines
15 KiB
Go
594 lines
15 KiB
Go
package handler
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"sort"
|
|
"sync"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"gopkg.in/yaml.v3"
|
|
|
|
"github.com/anotherhadi/iknowyou/config"
|
|
"github.com/anotherhadi/iknowyou/internal/respond"
|
|
"github.com/anotherhadi/iknowyou/internal/tools"
|
|
)
|
|
|
|
type ConfigHandler struct {
|
|
configPath string
|
|
factories []func() tools.ToolRunner
|
|
demo bool
|
|
mu sync.Mutex
|
|
}
|
|
|
|
func NewConfigHandler(configPath string, factories []func() tools.ToolRunner, demo bool) *ConfigHandler {
|
|
return &ConfigHandler{configPath: configPath, factories: factories, demo: demo}
|
|
}
|
|
|
|
// GET /api/config
|
|
func (h *ConfigHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|
cfg, err := config.Load(h.configPath)
|
|
if err != nil {
|
|
respond.Error(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
toolConfigs := make(map[string]any, len(cfg.Tools))
|
|
for toolName, node := range cfg.Tools {
|
|
var m map[string]any
|
|
if err := node.Decode(&m); err == nil {
|
|
toolConfigs[toolName] = m
|
|
}
|
|
}
|
|
respond.JSON(w, http.StatusOK, map[string]any{
|
|
"tools": toolConfigs,
|
|
"profiles": cfg.Profiles,
|
|
"readonly": h.demo || config.IsReadonly(h.configPath),
|
|
"demo": h.demo,
|
|
})
|
|
}
|
|
|
|
type profileSummary struct {
|
|
Name string `json:"name"`
|
|
Notes string `json:"notes,omitempty"`
|
|
Readonly bool `json:"readonly"`
|
|
}
|
|
|
|
// GET /api/config/profiles
|
|
func (h *ConfigHandler) ListProfiles(w http.ResponseWriter, r *http.Request) {
|
|
cfg, err := config.Load(h.configPath)
|
|
if err != nil {
|
|
respond.Error(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
builtinNames := make([]string, 0, len(config.BuiltinProfiles))
|
|
for name := range config.BuiltinProfiles {
|
|
builtinNames = append(builtinNames, name)
|
|
}
|
|
sort.Strings(builtinNames)
|
|
summaries := make([]profileSummary, 0, len(builtinNames)+len(cfg.Profiles))
|
|
for _, name := range builtinNames {
|
|
summaries = append(summaries, profileSummary{Name: name, Notes: config.BuiltinProfiles[name].Notes, Readonly: true})
|
|
}
|
|
|
|
names := make([]string, 0, len(cfg.Profiles))
|
|
for name := range cfg.Profiles {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
for _, name := range names {
|
|
p := cfg.Profiles[name]
|
|
summaries = append(summaries, profileSummary{Name: name, Notes: p.Notes, Readonly: false})
|
|
}
|
|
|
|
respond.JSON(w, http.StatusOK, summaries)
|
|
}
|
|
|
|
type profileDetail struct {
|
|
Name string `json:"name"`
|
|
Notes string `json:"notes,omitempty"`
|
|
Readonly bool `json:"readonly"`
|
|
Enabled []string `json:"enabled"`
|
|
Disabled []string `json:"disabled"`
|
|
Tools map[string]any `json:"tools"`
|
|
ActiveTools []string `json:"active_tools"`
|
|
}
|
|
|
|
// GET /api/config/profiles/{name}
|
|
func (h *ConfigHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
|
|
name := chi.URLParam(r, "name")
|
|
|
|
allNames := make([]string, 0, len(h.factories))
|
|
for _, factory := range h.factories {
|
|
allNames = append(allNames, factory().Name())
|
|
}
|
|
|
|
if builtin, ok := config.BuiltinProfiles[name]; ok {
|
|
activeTools := config.ActiveToolsForProfile(builtin.Profile, allNames)
|
|
if activeTools == nil {
|
|
activeTools = allNames
|
|
}
|
|
enabled := builtin.Profile.Enabled
|
|
if enabled == nil {
|
|
enabled = []string{}
|
|
}
|
|
disabled := builtin.Profile.Disabled
|
|
if disabled == nil {
|
|
disabled = []string{}
|
|
}
|
|
respond.JSON(w, http.StatusOK, profileDetail{
|
|
Name: name,
|
|
Notes: builtin.Notes,
|
|
Readonly: true,
|
|
Enabled: enabled,
|
|
Disabled: disabled,
|
|
Tools: map[string]any{},
|
|
ActiveTools: activeTools,
|
|
})
|
|
return
|
|
}
|
|
|
|
cfg, err := config.Load(h.configPath)
|
|
if err != nil {
|
|
respond.Error(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
p, ok := cfg.Profiles[name]
|
|
if !ok {
|
|
respond.Error(w, http.StatusNotFound, "profile not found")
|
|
return
|
|
}
|
|
|
|
toolOverrides := make(map[string]any, len(p.Tools))
|
|
for toolName, node := range p.Tools {
|
|
var m map[string]any
|
|
if err := node.Decode(&m); err == nil {
|
|
toolOverrides[toolName] = m
|
|
}
|
|
}
|
|
|
|
activeTools, _ := cfg.ActiveTools(name, allNames)
|
|
|
|
enabled := p.Enabled
|
|
if enabled == nil {
|
|
enabled = []string{}
|
|
}
|
|
disabled := p.Disabled
|
|
if disabled == nil {
|
|
disabled = []string{}
|
|
}
|
|
|
|
respond.JSON(w, http.StatusOK, profileDetail{
|
|
Name: name,
|
|
Notes: p.Notes,
|
|
Readonly: false,
|
|
Enabled: enabled,
|
|
Disabled: disabled,
|
|
Tools: toolOverrides,
|
|
ActiveTools: activeTools,
|
|
})
|
|
}
|
|
|
|
// POST /api/config/profiles
|
|
func (h *ConfigHandler) CreateProfile(w http.ResponseWriter, r *http.Request) {
|
|
if h.demo {
|
|
respond.Error(w, http.StatusForbidden, "demo mode: modifications are disabled")
|
|
return
|
|
}
|
|
if config.IsReadonly(h.configPath) {
|
|
respond.Error(w, http.StatusForbidden, "config is read-only")
|
|
return
|
|
}
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
Notes string `json:"notes"`
|
|
Enabled []string `json:"enabled"`
|
|
Disabled []string `json:"disabled"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
respond.Error(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
|
return
|
|
}
|
|
if req.Name == "" {
|
|
respond.Error(w, http.StatusBadRequest, "name is required")
|
|
return
|
|
}
|
|
if err := validateProfileName(req.Name); err != nil {
|
|
respond.Error(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
if _, isBuiltin := config.BuiltinProfiles[req.Name]; isBuiltin {
|
|
respond.Error(w, http.StatusForbidden, fmt.Sprintf("profile %q is reserved", req.Name))
|
|
return
|
|
}
|
|
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
|
|
cfg, err := config.Load(h.configPath)
|
|
if err != nil {
|
|
respond.Error(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
if _, exists := cfg.Profiles[req.Name]; exists {
|
|
respond.Error(w, http.StatusConflict, "profile already exists")
|
|
return
|
|
}
|
|
|
|
cfg.Profiles[req.Name] = config.Profile{
|
|
Notes: req.Notes,
|
|
Enabled: req.Enabled,
|
|
Disabled: req.Disabled,
|
|
}
|
|
if err := config.Save(h.configPath, cfg); err != nil {
|
|
respond.Error(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
respond.JSON(w, http.StatusCreated, cfg.Profiles[req.Name])
|
|
}
|
|
|
|
// PATCH /api/config/profiles/{name}
|
|
func (h *ConfigHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
|
if h.demo {
|
|
respond.Error(w, http.StatusForbidden, "demo mode: modifications are disabled")
|
|
return
|
|
}
|
|
if config.IsReadonly(h.configPath) {
|
|
respond.Error(w, http.StatusForbidden, "config is read-only")
|
|
return
|
|
}
|
|
name := chi.URLParam(r, "name")
|
|
if _, isBuiltin := config.BuiltinProfiles[name]; isBuiltin {
|
|
respond.Error(w, http.StatusForbidden, fmt.Sprintf("profile %q is read-only", name))
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Notes *string `json:"notes"`
|
|
Enabled *[]string `json:"enabled"`
|
|
Disabled *[]string `json:"disabled"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
respond.Error(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
|
return
|
|
}
|
|
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
|
|
cfg, err := config.Load(h.configPath)
|
|
if err != nil {
|
|
respond.Error(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
p, ok := cfg.Profiles[name]
|
|
if !ok {
|
|
respond.Error(w, http.StatusNotFound, "profile not found")
|
|
return
|
|
}
|
|
|
|
if req.Notes != nil {
|
|
p.Notes = *req.Notes
|
|
}
|
|
if req.Enabled != nil {
|
|
p.Enabled = *req.Enabled
|
|
}
|
|
if req.Disabled != nil {
|
|
p.Disabled = *req.Disabled
|
|
}
|
|
cfg.Profiles[name] = p
|
|
|
|
if err := config.Save(h.configPath, cfg); err != nil {
|
|
respond.Error(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
respond.JSON(w, http.StatusOK, p)
|
|
}
|
|
|
|
// DELETE /api/config/profiles/{name}
|
|
func (h *ConfigHandler) DeleteProfile(w http.ResponseWriter, r *http.Request) {
|
|
if h.demo {
|
|
respond.Error(w, http.StatusForbidden, "demo mode: modifications are disabled")
|
|
return
|
|
}
|
|
if config.IsReadonly(h.configPath) {
|
|
respond.Error(w, http.StatusForbidden, "config is read-only")
|
|
return
|
|
}
|
|
name := chi.URLParam(r, "name")
|
|
if _, isBuiltin := config.BuiltinProfiles[name]; isBuiltin {
|
|
respond.Error(w, http.StatusForbidden, fmt.Sprintf("profile %q is read-only", name))
|
|
return
|
|
}
|
|
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
|
|
cfg, err := config.Load(h.configPath)
|
|
if err != nil {
|
|
respond.Error(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
if _, ok := cfg.Profiles[name]; !ok {
|
|
respond.Error(w, http.StatusNotFound, "profile not found")
|
|
return
|
|
}
|
|
delete(cfg.Profiles, name)
|
|
|
|
if err := config.Save(h.configPath, cfg); err != nil {
|
|
respond.Error(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// PATCH /api/config/tools/{toolName}
|
|
func (h *ConfigHandler) UpdateToolConfig(w http.ResponseWriter, r *http.Request) {
|
|
if h.demo {
|
|
respond.Error(w, http.StatusForbidden, "demo mode: modifications are disabled")
|
|
return
|
|
}
|
|
if config.IsReadonly(h.configPath) {
|
|
respond.Error(w, http.StatusForbidden, "config is read-only")
|
|
return
|
|
}
|
|
toolName := chi.URLParam(r, "toolName")
|
|
|
|
var patch map[string]any
|
|
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
|
|
respond.Error(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
|
return
|
|
}
|
|
if err := h.validatePatch(toolName, patch); err != nil {
|
|
respond.Error(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
|
|
cfg, err := config.Load(h.configPath)
|
|
if err != nil {
|
|
respond.Error(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
merged, err := config.MergeNodePatch(cfg.Tools[toolName], patch)
|
|
if err != nil {
|
|
respond.Error(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
cfg.Tools[toolName] = merged
|
|
|
|
if err := config.Save(h.configPath, cfg); err != nil {
|
|
respond.Error(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
var result map[string]any
|
|
_ = merged.Decode(&result)
|
|
respond.JSON(w, http.StatusOK, result)
|
|
}
|
|
|
|
// DELETE /api/config/tools/{toolName}
|
|
func (h *ConfigHandler) DeleteToolConfig(w http.ResponseWriter, r *http.Request) {
|
|
if h.demo {
|
|
respond.Error(w, http.StatusForbidden, "demo mode: modifications are disabled")
|
|
return
|
|
}
|
|
if config.IsReadonly(h.configPath) {
|
|
respond.Error(w, http.StatusForbidden, "config is read-only")
|
|
return
|
|
}
|
|
toolName := chi.URLParam(r, "toolName")
|
|
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
|
|
cfg, err := config.Load(h.configPath)
|
|
if err != nil {
|
|
respond.Error(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
if _, ok := cfg.Tools[toolName]; !ok {
|
|
respond.Error(w, http.StatusNotFound, "no global config for this tool")
|
|
return
|
|
}
|
|
delete(cfg.Tools, toolName)
|
|
|
|
if err := config.Save(h.configPath, cfg); err != nil {
|
|
respond.Error(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// PATCH /api/config/profiles/{name}/tools/{toolName}
|
|
func (h *ConfigHandler) UpdateProfileToolConfig(w http.ResponseWriter, r *http.Request) {
|
|
if h.demo {
|
|
respond.Error(w, http.StatusForbidden, "demo mode: modifications are disabled")
|
|
return
|
|
}
|
|
if config.IsReadonly(h.configPath) {
|
|
respond.Error(w, http.StatusForbidden, "config is read-only")
|
|
return
|
|
}
|
|
name := chi.URLParam(r, "name")
|
|
toolName := chi.URLParam(r, "toolName")
|
|
|
|
if _, isBuiltin := config.BuiltinProfiles[name]; isBuiltin {
|
|
respond.Error(w, http.StatusForbidden, fmt.Sprintf("profile %q is read-only", name))
|
|
return
|
|
}
|
|
|
|
var patch map[string]any
|
|
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
|
|
respond.Error(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
|
return
|
|
}
|
|
if err := h.validatePatch(toolName, patch); err != nil {
|
|
respond.Error(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
|
|
cfg, err := config.Load(h.configPath)
|
|
if err != nil {
|
|
respond.Error(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
p, ok := cfg.Profiles[name]
|
|
if !ok {
|
|
respond.Error(w, http.StatusNotFound, "profile not found")
|
|
return
|
|
}
|
|
if p.Tools == nil {
|
|
p.Tools = make(map[string]yaml.Node)
|
|
}
|
|
|
|
merged, err := config.MergeNodePatch(p.Tools[toolName], patch)
|
|
if err != nil {
|
|
respond.Error(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
p.Tools[toolName] = merged
|
|
cfg.Profiles[name] = p
|
|
|
|
if err := config.Save(h.configPath, cfg); err != nil {
|
|
respond.Error(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
var result map[string]any
|
|
_ = merged.Decode(&result)
|
|
respond.JSON(w, http.StatusOK, result)
|
|
}
|
|
|
|
// DELETE /api/config/profiles/{name}/tools/{toolName}
|
|
func (h *ConfigHandler) DeleteProfileToolConfig(w http.ResponseWriter, r *http.Request) {
|
|
if h.demo {
|
|
respond.Error(w, http.StatusForbidden, "demo mode: modifications are disabled")
|
|
return
|
|
}
|
|
if config.IsReadonly(h.configPath) {
|
|
respond.Error(w, http.StatusForbidden, "config is read-only")
|
|
return
|
|
}
|
|
name := chi.URLParam(r, "name")
|
|
toolName := chi.URLParam(r, "toolName")
|
|
|
|
if _, isBuiltin := config.BuiltinProfiles[name]; isBuiltin {
|
|
respond.Error(w, http.StatusForbidden, fmt.Sprintf("profile %q is read-only", name))
|
|
return
|
|
}
|
|
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
|
|
cfg, err := config.Load(h.configPath)
|
|
if err != nil {
|
|
respond.Error(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
p, ok := cfg.Profiles[name]
|
|
if !ok {
|
|
respond.Error(w, http.StatusNotFound, "profile not found")
|
|
return
|
|
}
|
|
if _, ok := p.Tools[toolName]; !ok {
|
|
respond.Error(w, http.StatusNotFound, "no config override for this tool in profile")
|
|
return
|
|
}
|
|
delete(p.Tools, toolName)
|
|
cfg.Profiles[name] = p
|
|
|
|
if err := config.Save(h.configPath, cfg); err != nil {
|
|
respond.Error(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func validateProfileName(name string) error {
|
|
for _, c := range name {
|
|
if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') {
|
|
return fmt.Errorf("profile name must contain only lowercase letters (a-z), digits (0-9), and hyphens (-)")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *ConfigHandler) validatePatch(toolName string, patch map[string]any) error {
|
|
var fields []tools.ConfigField
|
|
for _, factory := range h.factories {
|
|
t := factory()
|
|
if t.Name() == toolName {
|
|
if d, ok := t.(tools.ConfigDescriber); ok {
|
|
fields = d.ConfigFields()
|
|
}
|
|
break
|
|
}
|
|
}
|
|
if len(fields) == 0 {
|
|
return nil
|
|
}
|
|
fieldMap := make(map[string]tools.ConfigField, len(fields))
|
|
for _, f := range fields {
|
|
fieldMap[f.Name] = f
|
|
}
|
|
for key, val := range patch {
|
|
f, ok := fieldMap[key]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if err := validateFieldValue(f, val); err != nil {
|
|
return fmt.Errorf("field %q: %w", key, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateFieldValue(f tools.ConfigField, val any) error {
|
|
if val == nil {
|
|
return nil
|
|
}
|
|
switch f.Type {
|
|
case "string":
|
|
if _, ok := val.(string); !ok {
|
|
return fmt.Errorf("expected string, got %T", val)
|
|
}
|
|
case "bool":
|
|
if _, ok := val.(bool); !ok {
|
|
return fmt.Errorf("expected bool, got %T", val)
|
|
}
|
|
case "int":
|
|
switch v := val.(type) {
|
|
case float64:
|
|
if v != float64(int64(v)) {
|
|
return fmt.Errorf("expected integer, got float")
|
|
}
|
|
default:
|
|
return fmt.Errorf("expected int, got %T", val)
|
|
}
|
|
case "float":
|
|
if _, ok := val.(float64); !ok {
|
|
return fmt.Errorf("expected number, got %T", val)
|
|
}
|
|
case "enum":
|
|
s, ok := val.(string)
|
|
if !ok {
|
|
return fmt.Errorf("expected string, got %T", val)
|
|
}
|
|
for _, opt := range f.Options {
|
|
if s == opt {
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("invalid value %q, must be one of: %v", s, f.Options)
|
|
}
|
|
return nil
|
|
}
|