mirror of
https://github.com/anotherhadi/iknowyou.git
synced 2026-04-12 00:47:26 +02:00
init
This commit is contained in:
593
back/internal/api/handler/config.go
Normal file
593
back/internal/api/handler/config.go
Normal 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
|
||||
}
|
||||
146
back/internal/api/handler/search.go
Normal file
146
back/internal/api/handler/search.go
Normal 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)
|
||||
}
|
||||
91
back/internal/api/handler/tools.go
Normal file
91
back/internal/api/handler/tools.go
Normal 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))
|
||||
}
|
||||
72
back/internal/api/middleware/ratelimit.go
Normal file
72
back/internal/api/middleware/ratelimit.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
type ipLimiter struct {
|
||||
limiter *rate.Limiter
|
||||
lastSeen time.Time
|
||||
}
|
||||
|
||||
type Limiter struct {
|
||||
mu sync.Mutex
|
||||
visitors map[string]*ipLimiter
|
||||
r rate.Limit
|
||||
burst int
|
||||
}
|
||||
|
||||
func New(r rate.Limit, burst int) *Limiter {
|
||||
l := &Limiter{
|
||||
visitors: make(map[string]*ipLimiter),
|
||||
r: r,
|
||||
burst: burst,
|
||||
}
|
||||
go l.cleanupLoop()
|
||||
return l
|
||||
}
|
||||
|
||||
func (l *Limiter) getLimiter(ip string) *rate.Limiter {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
v, exists := l.visitors[ip]
|
||||
if !exists {
|
||||
v = &ipLimiter{limiter: rate.NewLimiter(l.r, l.burst)}
|
||||
l.visitors[ip] = v
|
||||
}
|
||||
v.lastSeen = time.Now()
|
||||
return v.limiter
|
||||
}
|
||||
|
||||
func (l *Limiter) cleanupLoop() {
|
||||
ticker := time.NewTicker(10 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
l.mu.Lock()
|
||||
for ip, v := range l.visitors {
|
||||
if time.Since(v.lastSeen) > 10*time.Minute {
|
||||
delete(l.visitors, ip)
|
||||
}
|
||||
}
|
||||
l.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Limiter) Handler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ip, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
ip = r.RemoteAddr
|
||||
}
|
||||
if !l.getLimiter(ip).Allow() {
|
||||
http.Error(w, `{"error":"rate limit exceeded, please slow down"}`, http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
73
back/internal/api/router.go
Normal file
73
back/internal/api/router.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||
"golang.org/x/time/rate"
|
||||
|
||||
"github.com/anotherhadi/iknowyou/internal/api/handler"
|
||||
ikymiddleware "github.com/anotherhadi/iknowyou/internal/api/middleware"
|
||||
"github.com/anotherhadi/iknowyou/internal/search"
|
||||
"github.com/anotherhadi/iknowyou/internal/tools"
|
||||
)
|
||||
|
||||
func NewRouter(
|
||||
manager *search.Manager,
|
||||
factories []func() tools.ToolRunner,
|
||||
configPath string,
|
||||
frontDir string,
|
||||
demo bool,
|
||||
) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Use(chimiddleware.Logger)
|
||||
r.Use(chimiddleware.Recoverer)
|
||||
r.Use(chimiddleware.RequestID)
|
||||
|
||||
searchHandler := handler.NewSearchHandler(manager, demo)
|
||||
toolsHandler := handler.NewToolsHandler(factories)
|
||||
configHandler := handler.NewConfigHandler(configPath, factories, demo)
|
||||
|
||||
searchLimiter := ikymiddleware.New(rate.Every(10*time.Second), 3)
|
||||
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.Route("/searches", func(r chi.Router) {
|
||||
r.With(searchLimiter.Handler).Post("/", searchHandler.Create)
|
||||
r.Get("/", searchHandler.List)
|
||||
r.Get("/{id}", searchHandler.Get)
|
||||
r.Delete("/{id}", searchHandler.Delete)
|
||||
})
|
||||
|
||||
r.Route("/tools", func(r chi.Router) {
|
||||
r.Get("/", toolsHandler.List)
|
||||
r.Get("/{name}", toolsHandler.Get)
|
||||
})
|
||||
|
||||
r.Route("/config", func(r chi.Router) {
|
||||
r.Get("/", configHandler.Get)
|
||||
|
||||
r.Route("/tools", func(r chi.Router) {
|
||||
r.Patch("/{toolName}", configHandler.UpdateToolConfig)
|
||||
r.Delete("/{toolName}", configHandler.DeleteToolConfig)
|
||||
})
|
||||
|
||||
r.Route("/profiles", func(r chi.Router) {
|
||||
r.Get("/", configHandler.ListProfiles)
|
||||
r.Post("/", configHandler.CreateProfile)
|
||||
r.Get("/{name}", configHandler.GetProfile)
|
||||
r.Patch("/{name}", configHandler.UpdateProfile)
|
||||
r.Delete("/{name}", configHandler.DeleteProfile)
|
||||
r.Patch("/{name}/tools/{toolName}", configHandler.UpdateProfileToolConfig)
|
||||
r.Delete("/{name}/tools/{toolName}", configHandler.DeleteProfileToolConfig)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
if frontDir != "" {
|
||||
r.Handle("/*", newStaticHandler(frontDir))
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
56
back/internal/api/static.go
Normal file
56
back/internal/api/static.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// newStaticHandler serves the Astro static build with SPA fallbacks:
|
||||
// /search/<id> and /tools/<name> → their respective shell pages.
|
||||
func newStaticHandler(dir string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
urlPath := r.URL.Path
|
||||
|
||||
if strings.HasPrefix(urlPath, "/search/") && len(urlPath) > len("/search/") {
|
||||
http.ServeFile(w, r, filepath.Join(dir, "search", "_", "index.html"))
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(urlPath, "/tools/") {
|
||||
rest := strings.TrimPrefix(urlPath, "/tools/")
|
||||
if rest != "" && !strings.Contains(rest, "/") {
|
||||
http.ServeFile(w, r, filepath.Join(dir, "tools", "_", "index.html"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
rel := filepath.FromSlash(strings.TrimPrefix(urlPath, "/"))
|
||||
full := filepath.Join(dir, rel)
|
||||
|
||||
info, err := os.Stat(full)
|
||||
if err == nil && info.IsDir() {
|
||||
full = filepath.Join(full, "index.html")
|
||||
if _, err2 := os.Stat(full); err2 != nil {
|
||||
serve404(w, r, dir)
|
||||
return
|
||||
}
|
||||
} else if err != nil {
|
||||
serve404(w, r, dir)
|
||||
return
|
||||
}
|
||||
|
||||
http.ServeFile(w, r, full)
|
||||
})
|
||||
}
|
||||
|
||||
func serve404(w http.ResponseWriter, r *http.Request, dir string) {
|
||||
p := filepath.Join(dir, "404.html")
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
http.ServeFile(w, r, p)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
Reference in New Issue
Block a user