diff --git a/conf/default.php b/conf/default.php index 98e5e5b..feb1322 100644 --- a/conf/default.php +++ b/conf/default.php @@ -8,3 +8,7 @@ $conf['paths'] = ''; $conf['allow_in_comments'] = 0; $conf['defaults'] = ''; $conf['extensions'] = ''; + +// Local opener service used by {{open>...}}. +$conf['open_service_url'] = 'http://127.0.0.1:8765'; +$conf['open_service_token'] = ''; diff --git a/conf/metadata.php b/conf/metadata.php index 3fc01d6..ec3812c 100644 --- a/conf/metadata.php +++ b/conf/metadata.php @@ -11,3 +11,6 @@ $meta['paths'] = array(''); $meta['allow_in_comments'] = array('onoff'); $meta['defaults'] = array('string'); $meta['extensions'] = array('string'); + +$meta['open_service_url'] = array('string'); +$meta['open_service_token'] = array('string'); diff --git a/lang/de/settings.php b/lang/de/settings.php index be23671..5629d41 100644 --- a/lang/de/settings.php +++ b/lang/de/settings.php @@ -1,3 +1,6 @@ ...}} (z.B. http://127.0.0.1:8765).'; +$lang['open_service_token'] = 'Token für den lokalen Öffner-Dienst (X-Filetools-Token).'; diff --git a/lang/en/settings.php b/lang/en/settings.php index 519fc79..3c261be 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -3,3 +3,6 @@ $lang['allow_in_comments'] = 'Whether to allow the files syntax to be used in comments.'; $lang['defaults'] = 'Default options. Use the same syntax as in inline configuration'; $lang['extensions'] = 'Comma-separated list of allowed file extensions to list'; + +$lang['open_service_url'] = 'Local opener service URL for the {{open>...}} button (e.g. http://127.0.0.1:8765).'; +$lang['open_service_token'] = 'Token sent to the local opener service (X-Filetools-Token).'; diff --git a/lang/nl/settings.php b/lang/nl/settings.php index c2f349b..cda7a0a 100644 --- a/lang/nl/settings.php +++ b/lang/nl/settings.php @@ -7,3 +7,6 @@ $lang['allow_in_comments'] = 'Of de files syntax toegestaan is voor gebruik in commentaar.'; $lang['defaults'] = 'Default options. Gebruik dezelfde syntax als de inline configuratie.'; $lang['extensions'] = 'Komma-gescheiden lijst van toegestane bestandsextensies voor de lijst.'; + +$lang['open_service_url'] = 'Lokale opener service-URL voor de {{open>...}} knop (bijv. http://127.0.0.1:8765).'; +$lang['open_service_token'] = 'Token dat naar de lokale opener service wordt gestuurd (X-Filetools-Token).'; diff --git a/local-opener/go.mod b/local-opener/go.mod new file mode 100644 index 0000000..7fbadd1 --- /dev/null +++ b/local-opener/go.mod @@ -0,0 +1,3 @@ +module filetools-local-opener + +go 1.22 diff --git a/local-opener/main.go b/local-opener/main.go new file mode 100644 index 0000000..8c44122 --- /dev/null +++ b/local-opener/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) +} diff --git a/script.js b/script.js index ffb6d8d..6d9310d 100644 --- a/script.js +++ b/script.js @@ -3,6 +3,63 @@ (function () { 'use strict'; + function getServiceUrl(el) { + var url = el.getAttribute('data-service-url') || ''; + url = (url || '').trim(); + if (!url) return ''; + // strip trailing slashes + return url.replace(/\/+$/, ''); + } + + function getServiceToken(el) { + var token = el.getAttribute('data-service-token') || ''; + return (token || '').trim(); + } + + function pingOpenViaImage(el, rawPath) { + var baseUrl = getServiceUrl(el); + if (!baseUrl) return; + + var token = getServiceToken(el); + var url = baseUrl + '/open?path=' + encodeURIComponent(rawPath); + if (token) url += '&token=' + encodeURIComponent(token); + + // Fire-and-forget without CORS. + try { + var img = new window.Image(); + img.src = url; + } catch (e) { + // ignore + } + } + + function openViaService(el, rawPath) { + var baseUrl = getServiceUrl(el); + if (!baseUrl) return Promise.reject(new Error('No opener service configured')); + + var headers = { + 'Content-Type': 'application/json' + }; + var token = getServiceToken(el); + if (token) headers['X-Filetools-Token'] = token; + + return window.fetch(baseUrl + '/open', { + method: 'POST', + mode: 'cors', + credentials: 'omit', + headers: headers, + body: JSON.stringify({ path: rawPath }) + }).then(function (res) { + if (!res.ok) { + return res.json().catch(function () { return null; }).then(function (body) { + var msg = (body && body.message) ? body.message : ('HTTP ' + res.status); + throw new Error(msg); + }); + } + return res.json().catch(function () { return { ok: true }; }); + }); + } + function normalizeToFileUrl(path) { if (!path) return ''; @@ -37,21 +94,29 @@ if (!el || !el.classList || !el.classList.contains('filetools-open')) return; var raw = el.getAttribute('data-path') || ''; - var url = normalizeToFileUrl(raw); - console.log('Opening file URL:', url); - if (!url) return; + if (!raw) return; - // Best-effort: browsers may block file:// navigation depending on settings. - try { - window.open(url, '_blank', 'noopener'); - } catch (e) { - console.error('Failed to open file URL in new tab:', e); - try { - window.location.href = url; - } catch (e2) { - console.error('Failed to open file URL:', e2); - } - } + // Prefer local opener service. + openViaService(el, raw) + .catch(function (err) { + // If the browser blocks the request before it reaches localhost (mixed-content, + // extensions, stricter CORS handling), fall back to a no-CORS GET ping. + pingOpenViaImage(el, raw); + + // Fallback to old behavior (often blocked in modern browsers). + var url = normalizeToFileUrl(raw); + if (!url) return; + console.warn('Local opener service failed, falling back to file:// navigation:', err); + try { + window.open(url, '_blank', 'noopener'); + } catch (e) { + try { + window.location.href = url; + } catch (e2) { + console.error('Failed to open file URL:', e2); + } + } + }); } document.addEventListener('click', onClick, false); diff --git a/syntax/open.php b/syntax/open.php index 374e5e4..6239dd9 100644 --- a/syntax/open.php +++ b/syntax/open.php @@ -73,7 +73,19 @@ class syntax_plugin_filetools_open extends SyntaxPlugin return true; } - $renderer->doc .= ''; + $serviceUrl = trim((string)$this->getConf('open_service_url')); + $serviceToken = trim((string)$this->getConf('open_service_token')); + + $attrs = ' type="button" class="filetools-open"' + . ' data-path="' . hsc($path) . '"'; + if ($serviceUrl !== '') { + $attrs .= ' data-service-url="' . hsc($serviceUrl) . '"'; + } + if ($serviceToken !== '') { + $attrs .= ' data-service-token="' . hsc($serviceToken) . '"'; + } + + $renderer->doc .= '' . hsc($caption) . ''; return true; } }