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 .= '';
return true;
}
}