package main import ( "crypto/rand" "encoding/base64" "encoding/json" "errors" "flag" "fmt" "log" "net" "net/http" "net/url" "os" "os/exec" "path/filepath" "runtime" "strings" "time" "github.com/godbus/dbus/v5" ) 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"` } func main() { infoLog := log.New(os.Stdout, "", log.LstdFlags) errLog := log.New(os.Stderr, "ERROR: ", log.LstdFlags) listen := flag.String("listen", "127.0.0.1:8765", "listen address (host:port), should be loopback") token := flag.String("token", "", "shared secret token; if empty, requests are allowed without authentication") 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) } if strings.TrimSpace(*token) == "" { generated, err := generateToken() if err != nil { errLog.Fatalf("failed to generate token: %v", err) } *token = generated infoLog.Printf("generated token (set this in the plugin config): %s", *token) } mux := http.NewServeMux() mux.HandleFunc("/health", 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)}) }) mux.HandleFunc("/open", 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 if !checkToken(r, *token) { // Allow token to be supplied via query string for GET fallback. qt := strings.TrimSpace(r.URL.Query().Get("token")) if qt == "" || !subtleStringEqual(qt, strings.TrimSpace(*token)) { errLog.Printf("/open unauthorized method=%s path=%q headerToken=%t queryToken=%t dur=%s", r.Method, rawPath, strings.TrimSpace(r.Header.Get("X-Filetools-Token")) != "", qt != "", time.Since(start)) writeJSON(w, http.StatusUnauthorized, openResponse{OK: false, Message: "unauthorized"}) return } } target, err := normalizePath(req.Path) 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 } 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)) writeJSON(w, http.StatusForbidden, openResponse{OK: false, Message: "path not allowed"}) return } if err := openFolder(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)) 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 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 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 generateToken() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(b), nil } 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, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-Filetools-Token") } func checkToken(r *http.Request, required string) bool { required = strings.TrimSpace(required) if required == "" { return true } got := r.Header.Get("X-Filetools-Token") got = strings.TrimSpace(got) return got != "" && subtleStringEqual(got, required) } func subtleStringEqual(a, b string) bool { if len(a) != len(b) { return false } var v byte for i := 0; i < len(a); i++ { v |= a[i] ^ b[i] } return v == 0 } 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") } // 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 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 openFolder(path string) error { switch runtime.GOOS { case "windows": // explorer requires backslashes. p := strings.ReplaceAll(path, "/", "\\") cmd := exec.Command("explorer.exe", p) return cmd.Start() case "linux": if isKDESession() { if err := openFolderKDEDBus(path); err == nil { return nil } // Fallback: launching dolphin directly typically forwards to an existing // instance (opening a tab) and avoids portal/session-restore oddities. if err := exec.Command("dolphin", path).Start(); err == nil { return nil } } cmd := exec.Command("xdg-open", path) return cmd.Start() default: return fmt.Errorf("unsupported OS: %s", runtime.GOOS) } } func isKDESession() bool { // Plasma sets XDG_CURRENT_DESKTOP=KDE. Some setups provide multiple entries. cd := strings.ToLower(strings.TrimSpace(os.Getenv("XDG_CURRENT_DESKTOP"))) return strings.Contains(cd, "kde") } // openFolderKDEDBus asks the running file manager (Dolphin on Plasma) to show // the folder via D-Bus. This tends to reuse an existing Dolphin window and open // a new tab instead of spawning a new window and restoring unrelated session // tabs. // // Note: On Plasma Wayland, reliably forcing the window to the foreground is // gated by XDG activation tokens; we do a best-effort activation call but it may // be ignored by the compositor. func openFolderKDEDBus(path string) error { conn, err := dbus.SessionBus() if err != nil { return err } defer conn.Close() uri := (&url.URL{Scheme: "file", Path: filepath.ToSlash(path)}).String() obj := conn.Object("org.freedesktop.FileManager1", dbus.ObjectPath("/org/freedesktop/FileManager1")) call := obj.Call("org.freedesktop.FileManager1.ShowFolders", 0, []string{uri}, "") if call.Err != nil { return call.Err } // Best-effort activation of an existing Dolphin window. if dolphinSvc, _ := findFirstDBusName(conn, "org.kde.dolphin-"); dolphinSvc != "" { app := conn.Object(dolphinSvc, dbus.ObjectPath("/org/kde/dolphin")) _ = app.Call("org.freedesktop.Application.Activate", 0, map[string]dbus.Variant{}).Err } return nil } func findFirstDBusName(conn *dbus.Conn, prefix string) (string, error) { var names []string if err := conn.BusObject().Call("org.freedesktop.DBus.ListNames", 0).Store(&names); err != nil { return "", err } for _, n := range names { if strings.HasPrefix(n, prefix) { return n, nil } } return "", nil } func writeJSON(w http.ResponseWriter, status int, resp openResponse) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(resp) }