commit 7e95d1d3eaa00707910bfc4ba06952731970bf87 Author: luxick Date: Mon Jan 5 13:14:06 2026 +0100 Init diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5cbbf20 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module luxtools-client + +go 1.22 diff --git a/main.go b/main.go new file mode 100644 index 0000000..8c44122 --- /dev/null +++ b/main.go @@ -0,0 +1,267 @@ +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) +}