package main import ( "encoding/json" "errors" "flag" "fmt" "html" "html/template" "io" "log" "net" "net/http" "os" "path/filepath" "runtime" "runtime/debug" "strings" "sync" "time" "luxtools-client/internal/config" "luxtools-client/internal/installer" "luxtools-client/internal/notify" "luxtools-client/internal/openfolder" "luxtools-client/internal/web" ) var version = "dev" type endpointDoc struct { Path string Methods string Description string } func register(mux *http.ServeMux, docs *[]endpointDoc, path, methods, description string, handler http.HandlerFunc) { mux.HandleFunc(path, handler) *docs = append(*docs, endpointDoc{Path: path, Methods: methods, Description: description}) } type allowList []string func (a *allowList) String() string { return strings.Join(*a, ",") } func (a *allowList) Set(value string) error { value = strings.TrimSpace(value) if value == "" { return nil } *a = append(*a, value) return nil } type openRequest struct { Path string `json:"path"` } type openResponse struct { OK bool `json:"ok"` Message string `json:"message"` } type settingsConfig struct { PathMap map[string]string `json:"path_map"` } type configStore struct { mu sync.RWMutex cfg config.Config path string } func newConfigStore() (*configStore, error) { path, err := config.ConfigPath() if err != nil { return nil, err } cfg, loadErr := config.Load(path) store := &configStore{cfg: cfg, path: path} return store, loadErr } func (s *configStore) Snapshot() config.Config { s.mu.RLock() defer s.mu.RUnlock() return config.Config{PathMap: clonePathMap(s.cfg.PathMap)} } func (s *configStore) Update(cfg config.Config) error { if err := config.Save(s.path, cfg); err != nil { return err } s.mu.Lock() defer s.mu.Unlock() s.cfg = cfg return nil } func clonePathMap(in map[string]string) map[string]string { if in == nil { return map[string]string{} } out := make(map[string]string, len(in)) for k, v := range in { out[k] = v } return out } func buildInfoPayload() map[string]any { var deps []map[string]string if bi, ok := debug.ReadBuildInfo(); ok { for _, d := range bi.Deps { if d == nil { continue } deps = append(deps, map[string]string{"path": d.Path, "version": d.Version}) } } return map[string]any{ "ok": true, "time": time.Now().Format(time.RFC3339), "os": runtime.GOOS, "arch": runtime.GOARCH, "version": version, "goVersion": runtime.Version(), "deps": deps, } } func main() { infoLog := log.New(os.Stdout, "", log.LstdFlags) errLog := log.New(os.Stderr, "ERROR: ", log.LstdFlags) if len(os.Args) > 1 { switch os.Args[1] { case "install": if err := runInstall(os.Args[2:], infoLog, errLog); err != nil { errLog.Fatal(err) } return case "uninstall": if err := runUninstall(os.Args[2:], infoLog, errLog); err != nil { errLog.Fatal(err) } return } } listen := flag.String("listen", "127.0.0.1:8765", "listen address (host:port), should be loopback") debugNotify := flag.Bool("debug-notify", false, "debug: show OS notifications on successful actions") var allowed allowList flag.Var(&allowed, "allow", "allowed path prefix (repeatable); if none, any path is allowed") flag.Parse() if !isLoopbackListenAddr(*listen) { errLog.Fatalf("refusing to listen on non-loopback address: %s", *listen) } configStore, cfgErr := newConfigStore() if configStore == nil { errLog.Fatalf("config init failed") } if cfgErr != nil { errLog.Printf("config load error: %v", cfgErr) } mux := http.NewServeMux() var endpointDocs []endpointDoc register(mux, &endpointDocs, "/health", "GET", "Simple health check (JSON)", func(w http.ResponseWriter, r *http.Request) { withCORS(w, r) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "time": time.Now().Format(time.RFC3339)}) }) register(mux, &endpointDocs, "/info", "GET, OPTIONS", "Detailed client info (JSON)", func(w http.ResponseWriter, r *http.Request) { withCORS(w, r) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(buildInfoPayload()) }) register(mux, &endpointDocs, "/", "GET, OPTIONS", "Human-friendly status page", func(w http.ResponseWriter, r *http.Request) { withCORS(w, r) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) return } payload := buildInfoPayload() payloadJSON, _ := json.MarshalIndent(payload, "", " ") data := struct { Endpoints []endpointDoc InfoJSON template.HTML }{ Endpoints: endpointDocs, InfoJSON: template.HTML(html.EscapeString(string(payloadJSON))), } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := web.RenderIndex(w, data); err != nil { errLog.Printf("/ index-template error=%v", err) } }) register(mux, &endpointDocs, "/settings", "GET, OPTIONS", "Configure path aliases", func(w http.ResponseWriter, r *http.Request) { withCORS(w, r) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := web.RenderSettings(w); err != nil { errLog.Printf("/settings template error=%v", err) } }) register(mux, &endpointDocs, "/settings/config", "GET, POST, PUT, OPTIONS", "Read/update path alias config (JSON)", func(w http.ResponseWriter, r *http.Request) { withCORS(w, r) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } switch r.Method { case http.MethodGet: cfg := configStore.Snapshot() w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "path_map": cfg.PathMap}) return case http.MethodPost, http.MethodPut: dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 128*1024)) dec.DisallowUnknownFields() var req settingsConfig if err := dec.Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, openResponse{OK: false, Message: "invalid json"}) return } pathMap, err := validatePathMap(req.PathMap) if err != nil { writeJSON(w, http.StatusBadRequest, openResponse{OK: false, Message: err.Error()}) return } if err := configStore.Update(config.Config{PathMap: pathMap}); err != nil { writeJSON(w, http.StatusInternalServerError, openResponse{OK: false, Message: err.Error()}) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "path_map": pathMap}) return default: w.WriteHeader(http.StatusMethodNotAllowed) return } }) register(mux, &endpointDocs, "/open", "GET, POST, OPTIONS", "Open a folder in the OS file manager", func(w http.ResponseWriter, r *http.Request) { withCORS(w, r) start := time.Now() var rawPath string if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } var req openRequest switch r.Method { case http.MethodGet: req.Path = r.URL.Query().Get("path") case http.MethodPost: dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32*1024)) dec.DisallowUnknownFields() if err := dec.Decode(&req); err != nil { errLog.Printf("/open bad-json method=%s err=%v dur=%s", r.Method, err, time.Since(start)) writeJSON(w, http.StatusBadRequest, openResponse{OK: false, Message: "invalid json"}) return } default: errLog.Printf("/open method-not-allowed method=%s dur=%s", r.Method, time.Since(start)) writeJSON(w, http.StatusMethodNotAllowed, openResponse{OK: false, Message: "GET or POST required"}) return } rawPath = req.Path resolved, err := resolveInputPath(req.Path, configStore) if err != nil { errLog.Printf("/open bad-path method=%s path=%q err=%v dur=%s", r.Method, rawPath, err, time.Since(start)) writeJSON(w, http.StatusBadRequest, openResponse{OK: false, Message: err.Error()}) return } target, err := normalizePath(resolved) if err != nil { errLog.Printf("/open bad-path method=%s path=%q resolved=%q err=%v dur=%s", r.Method, rawPath, resolved, err, time.Since(start)) writeJSON(w, http.StatusBadRequest, openResponse{OK: false, Message: err.Error()}) return } if len(allowed) > 0 && !isAllowed(target, allowed) { errLog.Printf("/open forbidden method=%s path=%q normalized=%q dur=%s", r.Method, rawPath, target, time.Since(start)) notify.Show("luxtools-client", fmt.Sprintf("Refused to open (not allowed): %s", target)) writeJSON(w, http.StatusForbidden, openResponse{OK: false, Message: "path not allowed"}) return } if err := openfolder.OpenLocation(target); err != nil { errLog.Printf("/open open-failed method=%s path=%q normalized=%q err=%v dur=%s", r.Method, rawPath, target, err, time.Since(start)) notify.Show("luxtools-client", fmt.Sprintf("Failed to open: %s (%v)", target, err)) writeJSON(w, http.StatusInternalServerError, openResponse{OK: false, Message: err.Error()}) return } infoLog.Printf("/open opened method=%s path=%q normalized=%q dur=%s", r.Method, rawPath, target, time.Since(start)) if *debugNotify { notify.Show("luxtools-client", fmt.Sprintf("Opened: %s", target)) } if r.Method == http.MethodGet { // For GET callers (image-ping), a 204 avoids console noise from non-image responses. w.WriteHeader(http.StatusNoContent) return } writeJSON(w, http.StatusOK, openResponse{OK: true, Message: "opened"}) }) srv := &http.Server{ Addr: *listen, Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 30 * time.Second, } infoLog.Printf("listening on http://%s", *listen) infoLog.Printf("os=%s arch=%s", runtime.GOOS, runtime.GOARCH) errLog.Fatal(srv.ListenAndServe()) } func runInstall(args []string, infoLog, errLog *log.Logger) error { fs := flag.NewFlagSet("install", flag.ContinueOnError) fs.SetOutput(io.Discard) listen := fs.String("listen", "127.0.0.1:8765", "listen address (host:port), should be loopback") dryRun := fs.Bool("dry-run", false, "print/validate only; do not write files or register services") var allowed allowList fs.Var(&allowed, "allow", "allowed path prefix (repeatable); if none, any path is allowed") if err := fs.Parse(args); err != nil { return fmt.Errorf("install: %w", err) } if !installer.Supported() { return fmt.Errorf("install: unsupported OS: %s", runtime.GOOS) } if !isLoopbackListenAddr(*listen) { return fmt.Errorf("install: refusing non-loopback listen address: %s", *listen) } if err := installer.Install(installer.InstallOptions{Listen: *listen, Allow: []string(allowed), DryRun: *dryRun}); err != nil { return err } if *dryRun { infoLog.Printf("install dry-run OK") return nil } infoLog.Printf("installed %s", installer.ServiceName) return nil } func runUninstall(args []string, infoLog, errLog *log.Logger) error { fs := flag.NewFlagSet("uninstall", flag.ContinueOnError) fs.SetOutput(io.Discard) keepConfig := fs.Bool("keep-config", false, "keep config on disk") dryRun := fs.Bool("dry-run", false, "print/validate only; do not remove files or unregister services") if err := fs.Parse(args); err != nil { return fmt.Errorf("uninstall: %w", err) } if !installer.Supported() { return fmt.Errorf("uninstall: unsupported OS: %s", runtime.GOOS) } if err := installer.Uninstall(installer.UninstallOptions{KeepConfig: *keepConfig, DryRun: *dryRun}); err != nil { return err } if *dryRun { infoLog.Printf("uninstall dry-run OK") return nil } infoLog.Printf("uninstalled %s", installer.ServiceName) return nil } func isLoopbackListenAddr(addr string) bool { host, _, err := net.SplitHostPort(addr) if err != nil { return false } ip := net.ParseIP(host) if ip == nil { return host == "localhost" } return ip.IsLoopback() } func withCORS(w http.ResponseWriter, r *http.Request) { origin := r.Header.Get("Origin") if origin != "" { w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Vary", "Origin") } else { w.Header().Set("Access-Control-Allow-Origin", "*") } w.Header().Set("Access-Control-Allow-Methods", "POST, GET, PUT, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type") } func resolveInputPath(input string, store *configStore) (string, error) { p := strings.TrimSpace(input) if p == "" { return "", errors.New("missing path") } p = parseFileURL(p) idx := strings.Index(p, ">") if idx == -1 { return p, nil } if idx == 0 { return "", errors.New("alias is required before '>'") } alias := p[:idx] remainder := "" if len(p) > idx+1 { remainder = p[idx+1:] } cfg := store.Snapshot() root, ok := cfg.PathMap[alias] if !ok { notify.Show("luxtools-client", fmt.Sprintf("Unknown path alias: %s", alias)) return "", fmt.Errorf("unknown alias: %s", alias) } if strings.TrimSpace(root) == "" { return "", fmt.Errorf("alias root is empty: %s", alias) } if remainder == "" { return root, nil } remainder = strings.TrimLeft(remainder, "/\\") if remainder == "" { return root, nil } if filepath.IsAbs(remainder) || filepath.VolumeName(remainder) != "" { return "", errors.New("alias remainder must be a relative path") } return filepath.Join(root, filepath.FromSlash(remainder)), nil } func normalizePath(input string) (string, error) { p := strings.TrimSpace(input) if p == "" { return "", errors.New("missing path") } p = filepath.Clean(p) if !filepath.IsAbs(p) { return "", errors.New("path must be absolute") } // Ensure path exists. st, err := os.Stat(p) if err != nil { return "", fmt.Errorf("path not found") } if !st.IsDir() { // If a file is provided, open its containing folder. p = filepath.Dir(p) } return p, nil } func parseFileURL(p string) string { if strings.HasPrefix(strings.ToLower(p), "file://") { p = strings.TrimPrefix(p, "file://") // file:///C:/... becomes /C:/... (strip one leading slash) p = strings.TrimPrefix(p, "/") p = strings.TrimPrefix(p, "/") p = strings.TrimPrefix(p, "/") p = strings.ReplaceAll(p, "/", string(os.PathSeparator)) } return p } func normalizeAbsolutePath(input string) (string, error) { p := strings.TrimSpace(input) if p == "" { return "", errors.New("path is required") } p = parseFileURL(p) p = filepath.Clean(p) if !filepath.IsAbs(p) { return "", errors.New("path must be absolute") } return p, nil } func validatePathMap(in map[string]string) (map[string]string, error) { if in == nil { return map[string]string{}, nil } out := make(map[string]string, len(in)) for alias, path := range in { alias = strings.TrimSpace(alias) if alias == "" { return nil, errors.New("alias is required") } if strings.Contains(alias, ">") { return nil, errors.New("alias must not contain '>'") } if _, exists := out[alias]; exists { return nil, fmt.Errorf("duplicate alias: %s", alias) } normPath, err := normalizeAbsolutePath(path) if err != nil { return nil, fmt.Errorf("alias %s: %w", alias, err) } out[alias] = normPath } return out, nil } func isAllowed(path string, allowed []string) bool { path = filepath.Clean(path) for _, a := range allowed { a = filepath.Clean(a) if a == "." || a == string(os.PathSeparator) { return true } // Case-insensitive on Windows. if runtime.GOOS == "windows" { if strings.HasPrefix(strings.ToLower(path), strings.ToLower(a)) { return true } } else { if strings.HasPrefix(path, a) { return true } } } return false } func writeJSON(w http.ResponseWriter, status int, resp openResponse) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(resp) }