This commit is contained in:
Hadi
2026-04-06 15:12:34 +02:00
commit 4989225671
117 changed files with 11454 additions and 0 deletions

View File

@@ -0,0 +1,593 @@
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
}

View File

@@ -0,0 +1,146 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/anotherhadi/iknowyou/internal/respond"
"github.com/anotherhadi/iknowyou/internal/search"
"github.com/anotherhadi/iknowyou/internal/tools"
)
type SearchHandler struct {
manager *search.Manager
demo bool
}
func NewSearchHandler(manager *search.Manager, demo bool) *SearchHandler {
return &SearchHandler{manager: manager, demo: demo}
}
type postSearchRequest struct {
Target string `json:"target"`
InputType tools.InputType `json:"input_type"`
Profile string `json:"profile,omitempty"`
}
type searchSummary struct {
ID string `json:"id"`
Target string `json:"target"`
InputType tools.InputType `json:"input_type"`
Profile string `json:"profile,omitempty"`
Status search.Status `json:"status"`
StartedAt string `json:"started_at"`
PlannedTools []search.ToolStatus `json:"planned_tools"`
}
type searchDetail struct {
searchSummary
Events []tools.Event `json:"events"`
}
func toSummary(s *search.Search) searchSummary {
planned := s.PlannedTools
if planned == nil {
planned = []search.ToolStatus{}
}
return searchSummary{
ID: s.ID,
Target: s.Target,
InputType: s.InputType,
Profile: s.Profile,
Status: s.Status(),
StartedAt: s.StartedAt.UTC().Format("2006-01-02T15:04:05Z"),
PlannedTools: planned,
}
}
var validInputTypes = map[tools.InputType]struct{}{
tools.InputTypeEmail: {},
tools.InputTypeUsername: {},
tools.InputTypePhone: {},
tools.InputTypeIP: {},
tools.InputTypeDomain: {},
tools.InputTypePassword: {},
tools.InputTypeName: {},
}
// POST /searches
func (h *SearchHandler) Create(w http.ResponseWriter, r *http.Request) {
if h.demo {
respond.Error(w, http.StatusForbidden, "demo mode: searches are disabled")
return
}
var req postSearchRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respond.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if req.Target == "" {
respond.Error(w, http.StatusBadRequest, "target is required")
return
}
if len(req.Target) > 500 {
respond.Error(w, http.StatusBadRequest, "target is too long (max 500 characters)")
return
}
if req.Target[0] == '-' || req.Target[0] == '@' {
respond.Error(w, http.StatusBadRequest, "invalid target")
return
}
if req.InputType == "" {
respond.Error(w, http.StatusBadRequest, "input_type is required")
return
}
if _, ok := validInputTypes[req.InputType]; !ok {
respond.Error(w, http.StatusBadRequest, "invalid input_type")
return
}
s, err := h.manager.Start(context.WithoutCancel(r.Context()), req.Target, req.InputType, req.Profile)
if err != nil {
respond.Error(w, http.StatusInternalServerError, err.Error())
return
}
respond.JSON(w, http.StatusCreated, toSummary(s))
}
// GET /searches
func (h *SearchHandler) List(w http.ResponseWriter, r *http.Request) {
all := h.manager.All()
summaries := make([]searchSummary, len(all))
for i, s := range all {
summaries[i] = toSummary(s)
}
respond.JSON(w, http.StatusOK, summaries)
}
// GET /searches/{id}
func (h *SearchHandler) Get(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
s, err := h.manager.Get(id)
if err != nil {
respond.Error(w, http.StatusNotFound, err.Error())
return
}
detail := searchDetail{
searchSummary: toSummary(s),
Events: s.Events(),
}
respond.JSON(w, http.StatusOK, detail)
}
// DELETE /searches/{id}
func (h *SearchHandler) Delete(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := h.manager.Delete(id); err != nil {
respond.Error(w, http.StatusNotFound, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -0,0 +1,91 @@
package handler
import (
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/anotherhadi/iknowyou/internal/respond"
"github.com/anotherhadi/iknowyou/internal/tools"
)
type ToolsHandler struct {
factories []func() tools.ToolRunner
}
func NewToolsHandler(factories []func() tools.ToolRunner) *ToolsHandler {
return &ToolsHandler{factories: factories}
}
type toolInfo struct {
Name string `json:"name"`
Description string `json:"description"`
Link string `json:"link,omitempty"`
Icon string `json:"icon,omitempty"`
InputTypes []tools.InputType `json:"input_types"`
Configurable bool `json:"configurable"`
ConfigFields []tools.ConfigField `json:"config_fields,omitempty"`
Available *bool `json:"available,omitempty"`
UnavailableReason string `json:"unavailable_reason,omitempty"`
Dependencies []string `json:"dependencies,omitempty"`
}
func toToolInfo(t tools.ToolRunner) toolInfo {
_, configurable := t.(tools.Configurable)
var fields []tools.ConfigField
if d, ok := t.(tools.ConfigDescriber); ok {
fields = d.ConfigFields()
}
var available *bool
var unavailableReason string
if checker, ok := t.(tools.AvailabilityChecker); ok {
avail, reason := checker.Available()
available = &avail
if !avail {
unavailableReason = reason
}
}
var dependencies []string
if lister, ok := t.(tools.DependencyLister); ok {
dependencies = lister.Dependencies()
}
return toolInfo{
Name: t.Name(),
Description: t.Description(),
Link: t.Link(),
Icon: t.Icon(),
InputTypes: t.InputTypes(),
Configurable: configurable,
ConfigFields: fields,
Available: available,
UnavailableReason: unavailableReason,
Dependencies: dependencies,
}
}
// GET /api/tools
func (h *ToolsHandler) List(w http.ResponseWriter, r *http.Request) {
infos := make([]toolInfo, 0, len(h.factories))
for _, factory := range h.factories {
infos = append(infos, toToolInfo(factory()))
}
respond.JSON(w, http.StatusOK, infos)
}
// GET /api/tools/{name}
func (h *ToolsHandler) Get(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
for _, factory := range h.factories {
t := factory()
if t.Name() == name {
respond.JSON(w, http.StatusOK, toToolInfo(t))
return
}
}
respond.Error(w, http.StatusNotFound, fmt.Sprintf("tool %q not found", name))
}