package main import ( "crypto/rand" "encoding/base64" "encoding/json" "errors" "flag" "fmt" "log" "net" "net/http" "os" "os/exec" "path/filepath" "runtime" "strings" "time" ) 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() { 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) { log.Fatalf("refusing to listen on non-loopback address: %s", *listen) } if strings.TrimSpace(*token) == "" { generated, err := generateToken() if err != nil { log.Fatalf("failed to generate token: %v", err) } *token = generated log.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) 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 { writeJSON(w, http.StatusBadRequest, openResponse{OK: false, Message: "invalid json"}) return } default: writeJSON(w, http.StatusMethodNotAllowed, openResponse{OK: false, Message: "GET or POST required"}) return } 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)) { writeJSON(w, http.StatusUnauthorized, openResponse{OK: false, Message: "unauthorized"}) return } } target, err := normalizePath(req.Path) if err != nil { writeJSON(w, http.StatusBadRequest, openResponse{OK: false, Message: err.Error()}) return } if len(allowed) > 0 && !isAllowed(target, allowed) { writeJSON(w, http.StatusForbidden, openResponse{OK: false, Message: "path not allowed"}) return } if err := openFolder(target); err != nil { writeJSON(w, http.StatusInternalServerError, openResponse{OK: false, Message: err.Error()}) return } 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, } log.Printf("listening on http://%s", *listen) log.Printf("os=%s arch=%s", runtime.GOOS, runtime.GOARCH) log.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": cmd := exec.Command("xdg-open", path) return cmd.Start() default: return fmt.Errorf("unsupported OS: %s", runtime.GOOS) } } func writeJSON(w http.ResponseWriter, status int, resp openResponse) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(resp) }