Compare commits

...

5 Commits

Author SHA1 Message Date
8e369ebd5a No terminal window on windows 2026-02-13 20:59:47 +01:00
a9e626393c Ignore exe files 2026-02-13 20:36:40 +01:00
1979fbf9f1 fix windows install script 2026-02-13 20:36:06 +01:00
c4e53c5799 move templates to speparate file 2026-02-10 16:42:38 +01:00
48671fdd67 Add path mapping for OpenLocation 2026-02-10 16:38:48 +01:00
8 changed files with 724 additions and 62 deletions

3
.gitignore vendored
View File

@@ -1 +1,2 @@
dist/
dist/
*.exe

View File

@@ -6,6 +6,7 @@ DIST_DIR := dist
NATIVE_GOOS := $(shell go env GOOS)
NATIVE_GOARCH := $(shell go env GOARCH)
NATIVE_EXT := $(if $(filter windows,$(NATIVE_GOOS)),.exe,)
WINDOWS_GUI_LDFLAGS := $(if $(filter windows,$(NATIVE_GOOS)),-ldflags "-H=windowsgui",)
# Native (current platform)
NATIVE_EXE := $(APP_NAME)$(if $(filter windows,$(OS)),.exe,)
@@ -23,7 +24,7 @@ $(DIST_DIR):
build: $(DIST_DIR)
@echo "Building $(APP_NAME) for current platform..."
go build -trimpath -o $(DIST_DIR)/$(APP_NAME)$(NATIVE_EXT) .
go build -trimpath $(WINDOWS_GUI_LDFLAGS) -o $(DIST_DIR)/$(APP_NAME)$(NATIVE_EXT) .
build-linux: $(DIST_DIR)
@echo "Cross-compiling $(APP_NAME) for linux/amd64..."
@@ -31,7 +32,7 @@ build-linux: $(DIST_DIR)
build-windows: $(DIST_DIR)
@echo "Cross-compiling $(APP_NAME) for windows/amd64..."
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -o $(DIST_DIR)/$(APP_NAME)-windows-amd64.exe .
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags "-H=windowsgui" -o $(DIST_DIR)/$(APP_NAME)-windows-amd64.exe .
build-all: build-linux build-windows

View File

@@ -8,6 +8,7 @@ This program runs on the users machine and listens on a loopback address only
- Exposes `GET /health` for a simple health check.
- Exposes `GET /open?path=...` and `POST /open` to open a folder.
- Exposes `GET /control` (HTML) and `GET /logs` (text) to view recent logs.
- Normalizes and validates the requested path:
- Accepts absolute paths only.
- If a file path is provided, it opens the containing directory.
@@ -150,6 +151,8 @@ Notes:
- Installs to `%LOCALAPPDATA%\luxtools-client\luxtools-client.exe`
- Stores config in `%LOCALAPPDATA%\luxtools-client\config.json`
- Re-running `install` updates the EXE in place and refreshes the task.
- Windows builds use the GUI subsystem, so the app starts without a console window.
- Logs are written next to `config.json` and can be viewed at `http://127.0.0.1:8765/control` or `http://127.0.0.1:8765/logs`.
## API
@@ -195,6 +198,14 @@ curl -i \
'http://127.0.0.1:8765/open?path=/tmp'
```
### `GET /logs`
Returns recent log output as plain text.
### `GET /control`
Shows a simple HTML page with recent log output.
## OS support
- Linux: uses `xdg-open`.

87
internal/config/config.go Normal file
View File

@@ -0,0 +1,87 @@
package config
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
)
const appName = "luxtools-client"
// Config stores client configuration loaded from disk.
type Config struct {
PathMap map[string]string `json:"path_map"`
}
// ConfigPath returns the full path to the config.json file.
func ConfigPath() (string, error) {
dir, err := configDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "config.json"), nil
}
func configDir() (string, error) {
if dir, err := os.UserConfigDir(); err == nil && strings.TrimSpace(dir) != "" {
return filepath.Join(dir, appName), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
switch runtime.GOOS {
case "windows":
if v := strings.TrimSpace(os.Getenv("APPDATA")); v != "" {
return filepath.Join(v, appName), nil
}
return filepath.Join(home, "AppData", "Roaming", appName), nil
case "darwin":
return filepath.Join(home, "Library", "Application Support", appName), nil
default:
return filepath.Join(home, ".config", appName), nil
}
}
// Load reads the config from disk. If the file does not exist, it returns an empty config.
func Load(path string) (Config, error) {
cfg := Config{PathMap: map[string]string{}}
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return cfg, nil
}
return cfg, err
}
if len(b) == 0 {
return cfg, nil
}
if err := json.Unmarshal(b, &cfg); err != nil {
return cfg, err
}
if cfg.PathMap == nil {
cfg.PathMap = map[string]string{}
}
return cfg, nil
}
// Save writes the config to disk.
func Save(path string, cfg Config) error {
if cfg.PathMap == nil {
cfg.PathMap = map[string]string{}
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
b, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, b, 0o644)
}

View File

@@ -107,9 +107,9 @@ func registerWindowsScheduledTask(exePath, listen string, allow []string) error
ps := `
param(
[string]$ExePath,
[string]$Listen,
[string]$AllowJson
[string]$ExePath,
[string]$Listen,
[string]$AllowJson
)
$ErrorActionPreference = 'Stop'
$ServiceName = 'luxtools-client'
@@ -151,14 +151,30 @@ Register-ScheduledTask -TaskName $TaskName -InputObject $task -Force | Out-Null
try { Start-ScheduledTask -TaskName $TaskName | Out-Null } catch {}
`
psFile, err := os.CreateTemp("", "luxtools-client-install-*.ps1")
if err != nil {
return err
}
psPath := psFile.Name()
if _, err := psFile.WriteString(ps); err != nil {
_ = psFile.Close()
_ = os.Remove(psPath)
return err
}
if err := psFile.Close(); err != nil {
_ = os.Remove(psPath)
return err
}
defer os.Remove(psPath)
cmd := exec.Command("powershell.exe",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy", "Bypass",
"-Command", ps,
exePath,
listen,
string(allowJSON),
"-File", psPath,
"-ExePath", exePath,
"-Listen", listen,
"-AllowJson", string(allowJSON),
)
out, err := cmd.CombinedOutput()
if err != nil {

105
internal/logging/logging.go Normal file
View File

@@ -0,0 +1,105 @@
package logging
import (
"io"
"log"
"os"
"path/filepath"
"sync"
"luxtools-client/internal/config"
)
const (
logBufferMaxBytes = 256 * 1024
logFileMaxBytes = 5 * 1024 * 1024
)
// Buffer stores recent log output for control pages.
type Buffer struct {
mu sync.Mutex
buf []byte
max int
}
func newBuffer(max int) *Buffer {
return &Buffer{max: max}
}
func (b *Buffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
if len(p) > b.max {
p = p[len(p)-b.max:]
}
b.buf = append(b.buf, p...)
if len(b.buf) > b.max {
b.buf = b.buf[len(b.buf)-b.max:]
}
return len(p), nil
}
// Bytes returns a copy of the buffered log content.
func (b *Buffer) Bytes() []byte {
b.mu.Lock()
defer b.mu.Unlock()
if len(b.buf) == 0 {
return nil
}
copyBuf := make([]byte, len(b.buf))
copy(copyBuf, b.buf)
return copyBuf
}
func logFilePath() (string, error) {
configPath, err := config.ConfigPath()
if err != nil {
return "", err
}
return filepath.Join(filepath.Dir(configPath), "luxtools-client.log"), nil
}
func prepareLogFile(path string, maxBytes int64) (*os.File, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, err
}
if info, err := os.Stat(path); err == nil && info.Size() > maxBytes {
_ = os.Remove(path + ".old")
_ = os.Rename(path, path+".old")
}
return os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
}
// Setup builds loggers, a buffer, and optional file logging.
func Setup() (*log.Logger, *log.Logger, *Buffer, string, func()) {
logStore := newBuffer(logBufferMaxBytes)
logPath, logErr := logFilePath()
var logFile *os.File
if logErr == nil {
if f, err := prepareLogFile(logPath, logFileMaxBytes); err == nil {
logFile = f
} else {
logPath = ""
}
} else {
logPath = ""
}
infoWriters := []io.Writer{logStore, os.Stdout}
errWriters := []io.Writer{logStore, os.Stderr}
if logFile != nil {
infoWriters = append(infoWriters, logFile)
errWriters = append(errWriters, logFile)
}
infoLog := log.New(io.MultiWriter(infoWriters...), "", log.LstdFlags)
errLog := log.New(io.MultiWriter(errWriters...), "ERROR: ", log.LstdFlags)
cleanup := func() {}
if logFile != nil {
cleanup = func() { _ = logFile.Close() }
}
return infoLog, errLog, logStore, logPath, cleanup
}

221
internal/web/templates.go Normal file
View File

@@ -0,0 +1,221 @@
package web
import (
"html/template"
"io"
)
var indexTemplate = template.Must(template.New("index").Parse(`<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>luxtools-client</title>
<style>
body { font-family: system-ui, sans-serif; margin: 1.25rem; }
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
table { border-collapse: collapse; }
th, td { border-bottom: 1px solid #ddd; padding: 0.4rem 0.6rem; text-align: left; vertical-align: top; }
</style>
</head>
<body>
<h1>luxtools-client</h1>
<h2>Endpoints</h2>
<table>
<thead>
<tr><th>Path</th><th>Methods</th><th>Description</th></tr>
</thead>
<tbody>
{{- range .Endpoints }}
<tr>
<td><a href="{{ .Path }}"><code>{{ .Path }}</code></a></td>
<td><code>{{ .Methods }}</code></td>
<td>{{ .Description }}</td>
</tr>
{{- end }}
</tbody>
</table>
<h2>Info</h2>
<pre>{{ .InfoJSON }}</pre>
</body>
</html>
`))
var settingsTemplate = template.Must(template.New("settings").Parse(`<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>luxtools-client Settings</title>
<style>
body { font-family: system-ui, sans-serif; margin: 1.25rem; }
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
table { border-collapse: collapse; width: 100%; max-width: 900px; }
th, td { border-bottom: 1px solid #ddd; padding: 0.4rem 0.6rem; text-align: left; vertical-align: top; }
input[type=text] { width: 100%; box-sizing: border-box; padding: 0.35rem; }
button { padding: 0.35rem 0.7rem; }
.small { color: #666; font-size: 0.9rem; }
#status { margin-top: 0.75rem; }
</style>
</head>
<body>
<h1>Path Aliases</h1>
<p class="small">Define aliases like <code>PROJECTS</code> -> <code>/mnt/projects</code>. Use in <code>/open</code> as <code>PROJECTS&gt;my/repo</code>.</p>
<table>
<thead>
<tr><th style="width: 30%">Alias</th><th>Path</th><th style="width: 6rem"></th></tr>
</thead>
<tbody id="mapBody"></tbody>
</table>
<div style="margin-top: 0.75rem;">
<button id="addRow">Add Alias</button>
<button id="save">Save</button>
</div>
<div id="status" class="small"></div>
<script>
const bodyEl = document.getElementById('mapBody');
const statusEl = document.getElementById('status');
function setStatus(msg, isError) {
statusEl.textContent = msg || '';
statusEl.style.color = isError ? '#b00020' : '#00796b';
}
function addRow(alias = '', path = '') {
const tr = document.createElement('tr');
const aliasTd = document.createElement('td');
const pathTd = document.createElement('td');
const actionTd = document.createElement('td');
const aliasInput = document.createElement('input');
aliasInput.type = 'text';
aliasInput.value = alias;
aliasInput.placeholder = 'ALIAS';
aliasTd.appendChild(aliasInput);
const pathInput = document.createElement('input');
pathInput.type = 'text';
pathInput.value = path;
pathInput.placeholder = 'Absolute path';
pathTd.appendChild(pathInput);
const removeBtn = document.createElement('button');
removeBtn.textContent = 'Remove';
removeBtn.addEventListener('click', () => tr.remove());
actionTd.appendChild(removeBtn);
tr.appendChild(aliasTd);
tr.appendChild(pathTd);
tr.appendChild(actionTd);
bodyEl.appendChild(tr);
}
function isAbsolutePath(p) {
if (!p) return false;
if (p.startsWith('/') || p.startsWith('\\\\') || p.startsWith('//')) return true;
return /^[A-Za-z]:[\\/]/.test(p) || p.toLowerCase().startsWith('file://');
}
function collect() {
const rows = Array.from(bodyEl.querySelectorAll('tr'));
const map = {};
for (const row of rows) {
const alias = row.children[0].querySelector('input').value.trim();
const path = row.children[1].querySelector('input').value.trim();
if (!alias && !path) continue;
if (!alias) throw new Error('Alias is required');
if (alias.includes('>')) throw new Error('Alias must not contain ">"');
if (!path) throw new Error('Path is required');
if (!isAbsolutePath(path)) throw new Error('Path must be absolute');
if (map[alias]) throw new Error('Duplicate alias: ' + alias);
map[alias] = path;
}
return map;
}
async function loadConfig() {
setStatus('Loading...', false);
const res = await fetch('/settings/config');
const data = await res.json();
bodyEl.textContent = '';
if (!data.ok) {
setStatus(data.message || 'Failed to load config', true);
addRow();
return;
}
const keys = Object.keys(data.path_map || {}).sort();
if (keys.length === 0) addRow();
for (const k of keys) addRow(k, data.path_map[k]);
setStatus('Loaded', false);
}
async function saveConfig() {
try {
const map = collect();
setStatus('Saving...', false);
const res = await fetch('/settings/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path_map: map })
});
const data = await res.json();
if (!data.ok) {
setStatus(data.message || 'Failed to save config', true);
return;
}
setStatus('Saved', false);
} catch (err) {
setStatus(err.message || 'Validation error', true);
}
}
document.getElementById('addRow').addEventListener('click', () => addRow());
document.getElementById('save').addEventListener('click', () => saveConfig());
loadConfig();
</script>
</body>
</html>
`))
var logsTemplate = template.Must(template.New("logs").Parse(`<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>luxtools-client Logs</title>
<style>
body { font-family: system-ui, sans-serif; margin: 1.25rem; }
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
pre { background: #f7f7f7; padding: 0.75rem; border: 1px solid #ddd; overflow: auto; max-height: 70vh; }
.small { color: #666; font-size: 0.9rem; }
button { padding: 0.35rem 0.7rem; }
</style>
</head>
<body>
<h1>Logs</h1>
<p class="small">Log file: <code>{{ .LogPath }}</code></p>
<p class="small">Updated: <code>{{ .Updated }}</code></p>
<p>
<a href="/logs">View raw text</a>
<button type="button" onclick="location.reload()">Refresh</button>
</p>
<pre>{{ .LogText }}</pre>
</body>
</html>
`))
// RenderIndex renders the main index page.
func RenderIndex(w io.Writer, data any) error {
return indexTemplate.Execute(w, data)
}
// RenderSettings renders the settings page.
func RenderSettings(w io.Writer) error {
return settingsTemplate.Execute(w, nil)
}
// RenderLogs renders the logs control page.
func RenderLogs(w io.Writer, data any) error {
return logsTemplate.Execute(w, data)
}

324
main.go
View File

@@ -16,11 +16,15 @@ import (
"runtime"
"runtime/debug"
"strings"
"sync"
"time"
"luxtools-client/internal/config"
"luxtools-client/internal/installer"
"luxtools-client/internal/logging"
"luxtools-client/internal/notify"
"luxtools-client/internal/openfolder"
"luxtools-client/internal/web"
)
var version = "dev"
@@ -36,43 +40,6 @@ func register(mux *http.ServeMux, docs *[]endpointDoc, path, methods, descriptio
*docs = append(*docs, endpointDoc{Path: path, Methods: methods, Description: description})
}
var indexTemplate = template.Must(template.New("index").Parse(`<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>luxtools-client</title>
<style>
body { font-family: system-ui, sans-serif; margin: 1.25rem; }
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
table { border-collapse: collapse; }
th, td { border-bottom: 1px solid #ddd; padding: 0.4rem 0.6rem; text-align: left; vertical-align: top; }
</style>
</head>
<body>
<h1>luxtools-client</h1>
<h2>Endpoints</h2>
<table>
<thead>
<tr><th>Path</th><th>Methods</th><th>Description</th></tr>
</thead>
<tbody>
{{- range .Endpoints }}
<tr>
<td><a href="{{ .Path }}"><code>{{ .Path }}</code></a></td>
<td><code>{{ .Methods }}</code></td>
<td>{{ .Description }}</td>
</tr>
{{- end }}
</tbody>
</table>
<h2>Info</h2>
<pre>{{ .InfoJSON }}</pre>
</body>
</html>
`))
type allowList []string
func (a *allowList) String() string { return strings.Join(*a, ",") }
@@ -94,6 +61,53 @@ type openResponse struct {
Message string `json:"message"`
}
type settingsConfig struct {
PathMap map[string]string `json:"path_map"`
}
type configStore struct {
mu sync.RWMutex
cfg config.Config
path string
}
func newConfigStore() (*configStore, error) {
path, err := config.ConfigPath()
if err != nil {
return nil, err
}
cfg, loadErr := config.Load(path)
store := &configStore{cfg: cfg, path: path}
return store, loadErr
}
func (s *configStore) Snapshot() config.Config {
s.mu.RLock()
defer s.mu.RUnlock()
return config.Config{PathMap: clonePathMap(s.cfg.PathMap)}
}
func (s *configStore) Update(cfg config.Config) error {
if err := config.Save(s.path, cfg); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
s.cfg = cfg
return nil
}
func clonePathMap(in map[string]string) map[string]string {
if in == nil {
return map[string]string{}
}
out := make(map[string]string, len(in))
for k, v := range in {
out[k] = v
}
return out
}
func buildInfoPayload() map[string]any {
var deps []map[string]string
if bi, ok := debug.ReadBuildInfo(); ok {
@@ -116,8 +130,8 @@ func buildInfoPayload() map[string]any {
}
func main() {
infoLog := log.New(os.Stdout, "", log.LstdFlags)
errLog := log.New(os.Stderr, "ERROR: ", log.LstdFlags)
infoLog, errLog, logStore, logPath, cleanup := logging.Setup()
defer cleanup()
if len(os.Args) > 1 {
switch os.Args[1] {
@@ -144,6 +158,14 @@ func main() {
errLog.Fatalf("refusing to listen on non-loopback address: %s", *listen)
}
configStore, cfgErr := newConfigStore()
if configStore == nil {
errLog.Fatalf("config init failed")
}
if cfgErr != nil {
errLog.Printf("config load error: %v", cfgErr)
}
mux := http.NewServeMux()
var endpointDocs []endpointDoc
@@ -189,11 +211,117 @@ func main() {
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := indexTemplate.Execute(w, data); err != nil {
if err := web.RenderIndex(w, data); err != nil {
errLog.Printf("/ index-template error=%v", err)
}
})
register(mux, &endpointDocs, "/settings", "GET, OPTIONS", "Configure path aliases", func(w http.ResponseWriter, r *http.Request) {
withCORS(w, r)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := web.RenderSettings(w); err != nil {
errLog.Printf("/settings template error=%v", err)
}
})
register(mux, &endpointDocs, "/control", "GET, OPTIONS", "Logs control page", func(w http.ResponseWriter, r *http.Request) {
withCORS(w, r)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
displayPath := logPath
if displayPath == "" {
displayPath = "unavailable"
}
data := struct {
LogPath string
LogText template.HTML
Updated string
}{
LogPath: displayPath,
LogText: template.HTML(html.EscapeString(string(logStore.Bytes()))),
Updated: time.Now().Format(time.RFC3339),
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := web.RenderLogs(w, data); err != nil {
errLog.Printf("/control template error=%v", err)
}
})
register(mux, &endpointDocs, "/logs", "GET, OPTIONS", "Recent log output (text)", func(w http.ResponseWriter, r *http.Request) {
withCORS(w, r)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(logStore.Bytes())
})
register(mux, &endpointDocs, "/settings/config", "GET, POST, PUT, OPTIONS", "Read/update path alias config (JSON)", func(w http.ResponseWriter, r *http.Request) {
withCORS(w, r)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
switch r.Method {
case http.MethodGet:
cfg := configStore.Snapshot()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "path_map": cfg.PathMap})
return
case http.MethodPost, http.MethodPut:
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 128*1024))
dec.DisallowUnknownFields()
var req settingsConfig
if err := dec.Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, openResponse{OK: false, Message: "invalid json"})
return
}
pathMap, err := validatePathMap(req.PathMap)
if err != nil {
writeJSON(w, http.StatusBadRequest, openResponse{OK: false, Message: err.Error()})
return
}
if err := configStore.Update(config.Config{PathMap: pathMap}); err != nil {
writeJSON(w, http.StatusInternalServerError, openResponse{OK: false, Message: err.Error()})
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "path_map": pathMap})
return
default:
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
})
register(mux, &endpointDocs, "/open", "GET, POST, OPTIONS", "Open a folder in the OS file manager", func(w http.ResponseWriter, r *http.Request) {
withCORS(w, r)
start := time.Now()
@@ -223,13 +351,20 @@ func main() {
rawPath = req.Path
target, err := normalizePath(req.Path)
resolved, err := resolveInputPath(req.Path, configStore)
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
}
target, err := normalizePath(resolved)
if err != nil {
errLog.Printf("/open bad-path method=%s path=%q resolved=%q err=%v dur=%s", r.Method, rawPath, resolved, 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))
notify.Show("luxtools-client", fmt.Sprintf("Refused to open (not allowed): %s", target))
@@ -345,26 +480,61 @@ func withCORS(w http.ResponseWriter, r *http.Request) {
} else {
w.Header().Set("Access-Control-Allow-Origin", "*")
}
w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Methods", "POST, GET, PUT, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
}
func resolveInputPath(input string, store *configStore) (string, error) {
p := strings.TrimSpace(input)
if p == "" {
return "", errors.New("missing path")
}
p = parseFileURL(p)
idx := strings.Index(p, ">")
if idx == -1 {
return p, nil
}
if idx == 0 {
return "", errors.New("alias is required before '>'")
}
alias := p[:idx]
remainder := ""
if len(p) > idx+1 {
remainder = p[idx+1:]
}
cfg := store.Snapshot()
root, ok := cfg.PathMap[alias]
if !ok {
notify.Show("luxtools-client", fmt.Sprintf("Unknown path alias: %s", alias))
return "", fmt.Errorf("unknown alias: %s", alias)
}
if strings.TrimSpace(root) == "" {
return "", fmt.Errorf("alias root is empty: %s", alias)
}
if remainder == "" {
return root, nil
}
remainder = strings.TrimLeft(remainder, "/\\")
if remainder == "" {
return root, nil
}
if filepath.IsAbs(remainder) || filepath.VolumeName(remainder) != "" {
return "", errors.New("alias remainder must be a relative path")
}
return filepath.Join(root, filepath.FromSlash(remainder)), nil
}
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")
@@ -382,6 +552,56 @@ func normalizePath(input string) (string, error) {
return p, nil
}
func parseFileURL(p string) string {
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))
}
return p
}
func normalizeAbsolutePath(input string) (string, error) {
p := strings.TrimSpace(input)
if p == "" {
return "", errors.New("path is required")
}
p = parseFileURL(p)
p = filepath.Clean(p)
if !filepath.IsAbs(p) {
return "", errors.New("path must be absolute")
}
return p, nil
}
func validatePathMap(in map[string]string) (map[string]string, error) {
if in == nil {
return map[string]string{}, nil
}
out := make(map[string]string, len(in))
for alias, path := range in {
alias = strings.TrimSpace(alias)
if alias == "" {
return nil, errors.New("alias is required")
}
if strings.Contains(alias, ">") {
return nil, errors.New("alias must not contain '>'")
}
if _, exists := out[alias]; exists {
return nil, fmt.Errorf("duplicate alias: %s", alias)
}
normPath, err := normalizeAbsolutePath(path)
if err != nil {
return nil, fmt.Errorf("alias %s: %w", alias, err)
}
out[alias] = normPath
}
return out, nil
}
func isAllowed(path string, allowed []string) bool {
path = filepath.Clean(path)
for _, a := range allowed {