346 lines
9.7 KiB
Go
346 lines
9.7 KiB
Go
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)
|
|
}
|