diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..e654250 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,87 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" +) + +const appName = "luxtools-client" + +// Config stores client configuration loaded from disk. +type Config struct { + PathMap map[string]string `json:"path_map"` +} + +// ConfigPath returns the full path to the config.json file. +func ConfigPath() (string, error) { + dir, err := configDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "config.json"), nil +} + +func configDir() (string, error) { + if dir, err := os.UserConfigDir(); err == nil && strings.TrimSpace(dir) != "" { + return filepath.Join(dir, appName), nil + } + + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + + switch runtime.GOOS { + case "windows": + if v := strings.TrimSpace(os.Getenv("APPDATA")); v != "" { + return filepath.Join(v, appName), nil + } + return filepath.Join(home, "AppData", "Roaming", appName), nil + case "darwin": + return filepath.Join(home, "Library", "Application Support", appName), nil + default: + return filepath.Join(home, ".config", appName), nil + } +} + +// Load reads the config from disk. If the file does not exist, it returns an empty config. +func Load(path string) (Config, error) { + cfg := Config{PathMap: map[string]string{}} + + b, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return cfg, nil + } + return cfg, err + } + if len(b) == 0 { + return cfg, nil + } + + if err := json.Unmarshal(b, &cfg); err != nil { + return cfg, err + } + if cfg.PathMap == nil { + cfg.PathMap = map[string]string{} + } + return cfg, nil +} + +// Save writes the config to disk. +func Save(path string, cfg Config) error { + if cfg.PathMap == nil { + cfg.PathMap = map[string]string{} + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + b, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, b, 0o644) +} diff --git a/main.go b/main.go index 9e303a5..b19df6f 100644 --- a/main.go +++ b/main.go @@ -16,8 +16,10 @@ import ( "runtime" "runtime/debug" "strings" + "sync" "time" + "luxtools-client/internal/config" "luxtools-client/internal/installer" "luxtools-client/internal/notify" "luxtools-client/internal/openfolder" @@ -73,6 +75,143 @@ var indexTemplate = template.Must(template.New("index").Parse(` `)) +var settingsTemplate = template.Must(template.New("settings").Parse(` + + + + luxtools-client Settings + + + +

Path Aliases

+

Define aliases like PROJECTS -> /mnt/projects. Use in /open as PROJECTS>my/repo.

+ + + + + + +
AliasPath
+
+ + +
+
+ + + + +`)) + type allowList []string func (a *allowList) String() string { return strings.Join(*a, ",") } @@ -94,6 +233,53 @@ type openResponse struct { 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 { @@ -144,6 +330,14 @@ func main() { 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 @@ -194,6 +388,64 @@ func main() { } }) + 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 := settingsTemplate.Execute(w, nil); 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() @@ -223,13 +475,20 @@ func main() { rawPath = req.Path - target, err := normalizePath(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)) @@ -345,26 +604,61 @@ func withCORS(w http.ResponseWriter, r *http.Request) { } else { w.Header().Set("Access-Control-Allow-Origin", "*") } - w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS") + 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") } - // Accept file:// URLs. - 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)) - } - p = filepath.Clean(p) if !filepath.IsAbs(p) { return "", errors.New("path must be absolute") @@ -382,6 +676,56 @@ func normalizePath(input string) (string, error) { 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 {