Add path mapping for OpenLocation
This commit is contained in:
87
internal/config/config.go
Normal file
87
internal/config/config.go
Normal 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)
|
||||||
|
}
|
||||||
368
main.go
368
main.go
@@ -16,8 +16,10 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"luxtools-client/internal/config"
|
||||||
"luxtools-client/internal/installer"
|
"luxtools-client/internal/installer"
|
||||||
"luxtools-client/internal/notify"
|
"luxtools-client/internal/notify"
|
||||||
"luxtools-client/internal/openfolder"
|
"luxtools-client/internal/openfolder"
|
||||||
@@ -73,6 +75,143 @@ var indexTemplate = template.Must(template.New("index").Parse(`<!doctype html>
|
|||||||
</html>
|
</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>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>
|
||||||
|
`))
|
||||||
|
|
||||||
type allowList []string
|
type allowList []string
|
||||||
|
|
||||||
func (a *allowList) String() string { return strings.Join(*a, ",") }
|
func (a *allowList) String() string { return strings.Join(*a, ",") }
|
||||||
@@ -94,6 +233,53 @@ type openResponse struct {
|
|||||||
Message string `json:"message"`
|
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 {
|
func buildInfoPayload() map[string]any {
|
||||||
var deps []map[string]string
|
var deps []map[string]string
|
||||||
if bi, ok := debug.ReadBuildInfo(); ok {
|
if bi, ok := debug.ReadBuildInfo(); ok {
|
||||||
@@ -144,6 +330,14 @@ func main() {
|
|||||||
errLog.Fatalf("refusing to listen on non-loopback address: %s", *listen)
|
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()
|
mux := http.NewServeMux()
|
||||||
var endpointDocs []endpointDoc
|
var endpointDocs []endpointDoc
|
||||||
|
|
||||||
@@ -194,6 +388,64 @@ func main() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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 := settingsTemplate.Execute(w, nil); err != nil {
|
||||||
|
errLog.Printf("/settings template error=%v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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) {
|
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)
|
withCORS(w, r)
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
@@ -223,13 +475,20 @@ func main() {
|
|||||||
|
|
||||||
rawPath = req.Path
|
rawPath = req.Path
|
||||||
|
|
||||||
target, err := normalizePath(req.Path)
|
resolved, err := resolveInputPath(req.Path, configStore)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errLog.Printf("/open bad-path method=%s path=%q err=%v dur=%s", r.Method, rawPath, err, time.Since(start))
|
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()})
|
writeJSON(w, http.StatusBadRequest, openResponse{OK: false, Message: err.Error()})
|
||||||
return
|
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) {
|
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))
|
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))
|
notify.Show("luxtools-client", fmt.Sprintf("Refused to open (not allowed): %s", target))
|
||||||
@@ -345,26 +604,61 @@ func withCORS(w http.ResponseWriter, r *http.Request) {
|
|||||||
} else {
|
} else {
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
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")
|
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) {
|
func normalizePath(input string) (string, error) {
|
||||||
p := strings.TrimSpace(input)
|
p := strings.TrimSpace(input)
|
||||||
if p == "" {
|
if p == "" {
|
||||||
return "", errors.New("missing path")
|
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)
|
p = filepath.Clean(p)
|
||||||
if !filepath.IsAbs(p) {
|
if !filepath.IsAbs(p) {
|
||||||
return "", errors.New("path must be absolute")
|
return "", errors.New("path must be absolute")
|
||||||
@@ -382,6 +676,56 @@ func normalizePath(input string) (string, error) {
|
|||||||
return p, nil
|
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 {
|
func isAllowed(path string, allowed []string) bool {
|
||||||
path = filepath.Clean(path)
|
path = filepath.Clean(path)
|
||||||
for _, a := range allowed {
|
for _, a := range allowed {
|
||||||
|
|||||||
Reference in New Issue
Block a user