Client tool
This commit is contained in:
@@ -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'] = '';
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
<?php
|
||||
|
||||
$lang['allow_in_comments'] = 'Files-Syntax in Kommentaren erlauben.';
|
||||
|
||||
$lang['open_service_url'] = 'URL des lokalen Öffner-Dienstes für {{open>...}} (z.B. http://127.0.0.1:8765).';
|
||||
$lang['open_service_token'] = 'Token für den lokalen Öffner-Dienst (X-Filetools-Token).';
|
||||
|
||||
@@ -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).';
|
||||
|
||||
@@ -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).';
|
||||
|
||||
3
local-opener/go.mod
Normal file
3
local-opener/go.mod
Normal file
@@ -0,0 +1,3 @@
|
||||
module filetools-local-opener
|
||||
|
||||
go 1.22
|
||||
267
local-opener/main.go
Normal file
267
local-opener/main.go
Normal file
@@ -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)
|
||||
}
|
||||
75
script.js
75
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.
|
||||
// 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) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('click', onClick, false);
|
||||
|
||||
@@ -73,7 +73,19 @@ class syntax_plugin_filetools_open extends SyntaxPlugin
|
||||
return true;
|
||||
}
|
||||
|
||||
$renderer->doc .= '<button type="button" class="filetools-open" data-path="' . hsc($path) . '">' . hsc($caption) . '</button>';
|
||||
$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 .= '<button' . $attrs . '>' . hsc($caption) . '</button>';
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user