Compare commits
10 Commits
5763545f10
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e369ebd5a | |||
| a9e626393c | |||
| 1979fbf9f1 | |||
| c4e53c5799 | |||
| 48671fdd67 | |||
| c059a30e66 | |||
| 36dd53d2e4 | |||
| 0163d22f70 | |||
| 950e488190 | |||
| 997b165890 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,2 @@
|
|||||||
luxtools-client
|
dist/
|
||||||
*.exe
|
*.exe
|
||||||
.vscode/
|
|
||||||
45
.vscode/launch.json
vendored
Normal file
45
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Debug server (default)",
|
||||||
|
"type": "go",
|
||||||
|
"request": "launch",
|
||||||
|
"mode": "auto",
|
||||||
|
"program": "${workspaceFolder}",
|
||||||
|
"cwd": "${workspaceFolder}",
|
||||||
|
"args": ["-debug-notify"],
|
||||||
|
"console": "integratedTerminal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Debug server (port 9000)",
|
||||||
|
"type": "go",
|
||||||
|
"request": "launch",
|
||||||
|
"mode": "auto",
|
||||||
|
"program": "${workspaceFolder}",
|
||||||
|
"cwd": "${workspaceFolder}",
|
||||||
|
"args": ["-debug-notify", "-listen", "127.0.0.1:9000"],
|
||||||
|
"console": "integratedTerminal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Debug install (dry-run)",
|
||||||
|
"type": "go",
|
||||||
|
"request": "launch",
|
||||||
|
"mode": "auto",
|
||||||
|
"program": "${workspaceFolder}",
|
||||||
|
"cwd": "${workspaceFolder}",
|
||||||
|
"args": ["install", "-dry-run"],
|
||||||
|
"console": "integratedTerminal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Debug uninstall (dry-run)",
|
||||||
|
"type": "go",
|
||||||
|
"request": "launch",
|
||||||
|
"mode": "auto",
|
||||||
|
"program": "${workspaceFolder}",
|
||||||
|
"cwd": "${workspaceFolder}",
|
||||||
|
"args": ["uninstall", "-dry-run"],
|
||||||
|
"console": "integratedTerminal"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
19
.vscode/tasks.json
vendored
Normal file
19
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "build",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "make",
|
||||||
|
"args": ["build"],
|
||||||
|
"group": {
|
||||||
|
"kind": "build",
|
||||||
|
"isDefault": true
|
||||||
|
},
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "always",
|
||||||
|
"panel": "shared"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
40
Makefile
Normal file
40
Makefile
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
.PHONY: help build build-linux build-windows build-all clean
|
||||||
|
|
||||||
|
APP_NAME := luxtools-client
|
||||||
|
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,)
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "Targets:"
|
||||||
|
@echo " make build Build for current platform -> $(DIST_DIR)/$(APP_NAME)$(NATIVE_EXT) ($(NATIVE_GOOS)/$(NATIVE_GOARCH))"
|
||||||
|
@echo " make build-linux Cross-compile -> $(DIST_DIR)/$(APP_NAME)-linux-amd64"
|
||||||
|
@echo " make build-windows Cross-compile -> $(DIST_DIR)/$(APP_NAME)-windows-amd64.exe"
|
||||||
|
@echo " make build-all Build linux + windows artifacts"
|
||||||
|
@echo " make clean Remove $(DIST_DIR)/"
|
||||||
|
|
||||||
|
$(DIST_DIR):
|
||||||
|
@mkdir -p $(DIST_DIR)
|
||||||
|
|
||||||
|
build: $(DIST_DIR)
|
||||||
|
@echo "Building $(APP_NAME) for current platform..."
|
||||||
|
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..."
|
||||||
|
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -o $(DIST_DIR)/$(APP_NAME)-linux-amd64 .
|
||||||
|
|
||||||
|
build-windows: $(DIST_DIR)
|
||||||
|
@echo "Cross-compiling $(APP_NAME) for windows/amd64..."
|
||||||
|
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
|
||||||
|
|
||||||
|
clean:
|
||||||
|
@rm -rf $(DIST_DIR)
|
||||||
69
README.md
69
README.md
@@ -8,6 +8,7 @@ This program runs on the user’s machine and listens on a loopback address only
|
|||||||
|
|
||||||
- Exposes `GET /health` for a simple health check.
|
- Exposes `GET /health` for a simple health check.
|
||||||
- Exposes `GET /open?path=...` and `POST /open` to open a folder.
|
- 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:
|
- Normalizes and validates the requested path:
|
||||||
- Accepts absolute paths only.
|
- Accepts absolute paths only.
|
||||||
- If a file path is provided, it opens the containing directory.
|
- If a file path is provided, it opens the containing directory.
|
||||||
@@ -19,7 +20,24 @@ This program runs on the user’s machine and listens on a loopback address only
|
|||||||
Requires Go 1.22+.
|
Requires Go 1.22+.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go build -o luxtools-client .
|
make build
|
||||||
|
```
|
||||||
|
|
||||||
|
Output:
|
||||||
|
|
||||||
|
- `dist/luxtools-client` (or `dist/luxtools-client.exe` on Windows)
|
||||||
|
|
||||||
|
### Cross-compile
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Linux (amd64)
|
||||||
|
make build-linux
|
||||||
|
|
||||||
|
# Windows (amd64)
|
||||||
|
make build-windows
|
||||||
|
|
||||||
|
# Both
|
||||||
|
make build-all
|
||||||
```
|
```
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
@@ -45,43 +63,50 @@ Examples:
|
|||||||
|
|
||||||
## Install as a service
|
## Install as a service
|
||||||
|
|
||||||
The scripts below install (or update) the client as a service that starts automatically with the system.
|
Use the built-in `install` / `uninstall` commands to install (or update) the client as a per-user service that starts automatically.
|
||||||
They assume the client binary already exists in the same folder as the scripts.
|
The `install` command copies the currently-running binary into the appropriate per-user install location.
|
||||||
|
|
||||||
### Linux (systemd)
|
### Linux (systemd)
|
||||||
|
|
||||||
Install / update:
|
Install / update:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./install-linux.sh
|
./luxtools-client install
|
||||||
```
|
```
|
||||||
|
|
||||||
Optional flags:
|
Optional flags:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Change listen address (still must be loopback)
|
# Change listen address (still must be loopback)
|
||||||
./install-linux.sh --listen 127.0.0.1:9000
|
./luxtools-client install -listen 127.0.0.1:9000
|
||||||
|
|
||||||
# Restrict allowed folders (repeatable)
|
# Restrict allowed folders (repeatable)
|
||||||
./install-linux.sh --allow "$HOME" --allow "/mnt/data"
|
./luxtools-client install -allow "$HOME" -allow "/mnt/data"
|
||||||
|
|
||||||
|
# Validate only (no files written, no service changes)
|
||||||
|
./luxtools-client install -dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
Uninstall:
|
Uninstall:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./uninstall-linux.sh
|
./luxtools-client uninstall
|
||||||
```
|
```
|
||||||
|
|
||||||
Keep config on uninstall:
|
Keep config on uninstall:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./uninstall-linux.sh --keep-config
|
./luxtools-client uninstall -keep-config
|
||||||
|
|
||||||
|
# Validate only (no files removed, no service changes)
|
||||||
|
./luxtools-client uninstall -dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- Installs to `~/.local/share/luxtools-client/luxtools-client`
|
- Installs to `~/.local/share/luxtools-client/luxtools-client`
|
||||||
- Creates a systemd *user* unit at `~/.config/systemd/user/luxtools-client.service`
|
- Creates a systemd *user* unit at `~/.config/systemd/user/luxtools-client.service`
|
||||||
|
- Enables the unit under `graphical-session.target` (so it starts with the GUI session)
|
||||||
- Stores config in `~/.config/luxtools-client/luxtools-client.env`
|
- Stores config in `~/.config/luxtools-client/luxtools-client.env`
|
||||||
|
|
||||||
### Windows (Scheduled Task at logon)
|
### Windows (Scheduled Task at logon)
|
||||||
@@ -91,35 +116,43 @@ Because this tool needs to open File Explorer (a GUI app) in the *current user s
|
|||||||
Install / update:
|
Install / update:
|
||||||
|
|
||||||
```bat
|
```bat
|
||||||
install-windows-task.bat
|
luxtools-client.exe install
|
||||||
```
|
```
|
||||||
|
|
||||||
Optional flags:
|
Optional flags:
|
||||||
|
|
||||||
```bat
|
```bat
|
||||||
install-windows-task.bat --listen 127.0.0.1:9000
|
luxtools-client.exe install -listen 127.0.0.1:9000
|
||||||
|
|
||||||
REM Restrict allowed folders (repeatable)
|
REM Restrict allowed folders (repeatable)
|
||||||
install-windows-task.bat --allow "%USERPROFILE%" --allow "D:\Data"
|
luxtools-client.exe install -allow "%USERPROFILE%" -allow "D:\Data"
|
||||||
|
|
||||||
|
REM Validate only (no files written, no task changes)
|
||||||
|
luxtools-client.exe install -dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
Uninstall:
|
Uninstall:
|
||||||
|
|
||||||
```bat
|
```bat
|
||||||
uninstall-windows-task.bat
|
luxtools-client.exe uninstall
|
||||||
```
|
```
|
||||||
|
|
||||||
Keep config on uninstall:
|
Keep config on uninstall:
|
||||||
|
|
||||||
```bat
|
```bat
|
||||||
uninstall-windows-task.bat --keep-config
|
luxtools-client.exe uninstall -keep-config
|
||||||
|
|
||||||
|
REM Validate only (no files removed, no task changes)
|
||||||
|
luxtools-client.exe uninstall -dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- Installs to `%LOCALAPPDATA%\luxtools-client\luxtools-client.exe`
|
- Installs to `%LOCALAPPDATA%\luxtools-client\luxtools-client.exe`
|
||||||
- Stores config in `%LOCALAPPDATA%\luxtools-client\config.json`
|
- Stores config in `%LOCALAPPDATA%\luxtools-client\config.json`
|
||||||
- Re-running the install script updates the EXE in place and restarts the task.
|
- 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
|
## API
|
||||||
|
|
||||||
@@ -165,6 +198,14 @@ curl -i \
|
|||||||
'http://127.0.0.1:8765/open?path=/tmp'
|
'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
|
## OS support
|
||||||
|
|
||||||
- Linux: uses `xdg-open`.
|
- Linux: uses `xdg-open`.
|
||||||
|
|||||||
17
go.mod
17
go.mod
@@ -2,6 +2,19 @@ module luxtools-client
|
|||||||
|
|
||||||
go 1.22
|
go 1.22
|
||||||
|
|
||||||
require github.com/godbus/dbus/v5 v5.2.2
|
require (
|
||||||
|
github.com/gen2brain/beeep v0.11.2
|
||||||
|
github.com/godbus/dbus/v5 v5.2.2
|
||||||
|
)
|
||||||
|
|
||||||
require golang.org/x/sys v0.27.0 // indirect
|
require (
|
||||||
|
git.sr.ht/~jackmordaunt/go-toast v1.1.2 // indirect
|
||||||
|
github.com/esiqveland/notify v0.13.3 // indirect
|
||||||
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
|
github.com/jackmordaunt/icns/v3 v3.0.1 // indirect
|
||||||
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
|
||||||
|
github.com/sergeymakinen/go-bmp v1.0.0 // indirect
|
||||||
|
github.com/sergeymakinen/go-ico v1.0.0-beta.0 // indirect
|
||||||
|
github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect
|
||||||
|
golang.org/x/sys v0.30.0 // indirect
|
||||||
|
)
|
||||||
|
|||||||
40
go.sum
40
go.sum
@@ -1,4 +1,40 @@
|
|||||||
|
git.sr.ht/~jackmordaunt/go-toast v1.1.2 h1:/yrfI55LRt1M7H1vkaw+NaH1+L1CDxrqDltwm5euVuE=
|
||||||
|
git.sr.ht/~jackmordaunt/go-toast v1.1.2/go.mod h1:jA4OqHKTQ4AFBdwrSnwnskUIIS3HYzlJSgdzCKqfavo=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/esiqveland/notify v0.13.3 h1:QCMw6o1n+6rl+oLUfg8P1IIDSFsDEb2WlXvVvIJbI/o=
|
||||||
|
github.com/esiqveland/notify v0.13.3/go.mod h1:hesw/IRYTO0x99u1JPweAl4+5mwXJibQVUcP0Iu5ORE=
|
||||||
|
github.com/gen2brain/beeep v0.11.2 h1:+KfiKQBbQCuhfJFPANZuJ+oxsSKAYNe88hIpJuyKWDA=
|
||||||
|
github.com/gen2brain/beeep v0.11.2/go.mod h1:jQVvuwnLuwOcdctHn/uyh8horSBNJ8uGb9Cn2W4tvoc=
|
||||||
|
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
|
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||||
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
|
github.com/jackmordaunt/icns/v3 v3.0.1 h1:xxot6aNuGrU+lNgxz5I5H0qSeCjNKp8uTXB1j8D4S3o=
|
||||||
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
github.com/jackmordaunt/icns/v3 v3.0.1/go.mod h1:5sHL59nqTd2ynTnowxB/MDQFhKNqkK8X687uKNygaSQ=
|
||||||
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
||||||
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/sergeymakinen/go-bmp v1.0.0 h1:SdGTzp9WvCV0A1V0mBeaS7kQAwNLdVJbmHlqNWq0R+M=
|
||||||
|
github.com/sergeymakinen/go-bmp v1.0.0/go.mod h1:/mxlAQZRLxSvJFNIEGGLBE/m40f3ZnUifpgVDlcUIEY=
|
||||||
|
github.com/sergeymakinen/go-ico v1.0.0-beta.0 h1:m5qKH7uPKLdrygMWxbamVn+tl2HfiA3K6MFJw4GfZvQ=
|
||||||
|
github.com/sergeymakinen/go-ico v1.0.0-beta.0/go.mod h1:wQ47mTczswBO5F0NoDt7O0IXgnV4Xy3ojrroMQzyhUk=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk=
|
||||||
|
github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o=
|
||||||
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||||
|
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
@@ -60,10 +60,15 @@ func Install(opts InstallOptions) error {
|
|||||||
|
|
||||||
unit := `[Unit]
|
unit := `[Unit]
|
||||||
Description=luxtools-client (local folder opener helper)
|
Description=luxtools-client (local folder opener helper)
|
||||||
|
After=graphical-session.target dbus.service
|
||||||
|
Wants=dbus.service
|
||||||
|
PartOf=graphical-session.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
EnvironmentFile=%h/.config/luxtools-client/luxtools-client.env
|
EnvironmentFile=%h/.config/luxtools-client/luxtools-client.env
|
||||||
|
Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=%t/bus
|
||||||
|
ExecStartPre=/bin/sh -lc 'for i in $(seq 1 150); do systemctl --user show-environment | grep -qE "^(DISPLAY|WAYLAND_DISPLAY)=" && exit 0; sleep 0.2; done; echo "Timed out waiting for DISPLAY/WAYLAND_DISPLAY in systemd --user environment" >&2; systemctl --user show-environment >&2; exit 1'
|
||||||
ExecStart=/bin/sh -lc '%h/.local/share/luxtools-client/luxtools-client -listen "$LISTEN" $ALLOW_ARGS'
|
ExecStart=/bin/sh -lc '%h/.local/share/luxtools-client/luxtools-client -listen "$LISTEN" $ALLOW_ARGS'
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=1
|
RestartSec=1
|
||||||
@@ -72,7 +77,7 @@ NoNewPrivileges=true
|
|||||||
PrivateTmp=true
|
PrivateTmp=true
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=default.target
|
WantedBy=graphical-session.target
|
||||||
`
|
`
|
||||||
if err := os.WriteFile(unitFile, []byte(unit), 0o644); err != nil {
|
if err := os.WriteFile(unitFile, []byte(unit), 0o644); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -151,14 +151,30 @@ Register-ScheduledTask -TaskName $TaskName -InputObject $task -Force | Out-Null
|
|||||||
try { Start-ScheduledTask -TaskName $TaskName | Out-Null } catch {}
|
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",
|
cmd := exec.Command("powershell.exe",
|
||||||
"-NoProfile",
|
"-NoProfile",
|
||||||
"-NonInteractive",
|
"-NonInteractive",
|
||||||
"-ExecutionPolicy", "Bypass",
|
"-ExecutionPolicy", "Bypass",
|
||||||
"-Command", ps,
|
"-File", psPath,
|
||||||
exePath,
|
"-ExePath", exePath,
|
||||||
listen,
|
"-Listen", listen,
|
||||||
string(allowJSON),
|
"-AllowJson", string(allowJSON),
|
||||||
)
|
)
|
||||||
out, err := cmd.CombinedOutput()
|
out, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
105
internal/logging/logging.go
Normal file
105
internal/logging/logging.go
Normal 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
|
||||||
|
}
|
||||||
BIN
internal/notify/info.png
Executable file
BIN
internal/notify/info.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 6.3 KiB |
16
internal/notify/notify.go
Normal file
16
internal/notify/notify.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package notify
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
|
||||||
|
"github.com/gen2brain/beeep"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed info.png
|
||||||
|
var icon []byte
|
||||||
|
|
||||||
|
// Show shows a desktop notification if possible.
|
||||||
|
// It must never panic and callers should treat it as fire-and-forget.
|
||||||
|
func Show(title, message string) {
|
||||||
|
_ = beeep.Notify(title, message, icon)
|
||||||
|
}
|
||||||
221
internal/web/templates.go
Normal file
221
internal/web/templates.go
Normal 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>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)
|
||||||
|
}
|
||||||
377
main.go
377
main.go
@@ -5,6 +5,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"html/template"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
@@ -12,13 +14,32 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"runtime/debug"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"luxtools-client/internal/config"
|
||||||
"luxtools-client/internal/installer"
|
"luxtools-client/internal/installer"
|
||||||
|
"luxtools-client/internal/logging"
|
||||||
|
"luxtools-client/internal/notify"
|
||||||
"luxtools-client/internal/openfolder"
|
"luxtools-client/internal/openfolder"
|
||||||
|
"luxtools-client/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
|
type endpointDoc struct {
|
||||||
|
Path string
|
||||||
|
Methods string
|
||||||
|
Description string
|
||||||
|
}
|
||||||
|
|
||||||
|
func register(mux *http.ServeMux, docs *[]endpointDoc, path, methods, description string, handler http.HandlerFunc) {
|
||||||
|
mux.HandleFunc(path, handler)
|
||||||
|
*docs = append(*docs, endpointDoc{Path: path, Methods: methods, Description: description})
|
||||||
|
}
|
||||||
|
|
||||||
type allowList []string
|
type allowList []string
|
||||||
|
|
||||||
func (a *allowList) String() string { return strings.Join(*a, ",") }
|
func (a *allowList) String() string { return strings.Join(*a, ",") }
|
||||||
@@ -40,9 +61,77 @@ 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 {
|
||||||
|
var deps []map[string]string
|
||||||
|
if bi, ok := debug.ReadBuildInfo(); ok {
|
||||||
|
for _, d := range bi.Deps {
|
||||||
|
if d == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
deps = append(deps, map[string]string{"path": d.Path, "version": d.Version})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"ok": true,
|
||||||
|
"time": time.Now().Format(time.RFC3339),
|
||||||
|
"os": runtime.GOOS,
|
||||||
|
"arch": runtime.GOARCH,
|
||||||
|
"version": version,
|
||||||
|
"goVersion": runtime.Version(),
|
||||||
|
"deps": deps,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
infoLog := log.New(os.Stdout, "", log.LstdFlags)
|
infoLog, errLog, logStore, logPath, cleanup := logging.Setup()
|
||||||
errLog := log.New(os.Stderr, "ERROR: ", log.LstdFlags)
|
defer cleanup()
|
||||||
|
|
||||||
if len(os.Args) > 1 {
|
if len(os.Args) > 1 {
|
||||||
switch os.Args[1] {
|
switch os.Args[1] {
|
||||||
@@ -60,6 +149,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
listen := flag.String("listen", "127.0.0.1:8765", "listen address (host:port), should be loopback")
|
listen := flag.String("listen", "127.0.0.1:8765", "listen address (host:port), should be loopback")
|
||||||
|
debugNotify := flag.Bool("debug-notify", false, "debug: show OS notifications on successful actions")
|
||||||
var allowed allowList
|
var allowed allowList
|
||||||
flag.Var(&allowed, "allow", "allowed path prefix (repeatable); if none, any path is allowed")
|
flag.Var(&allowed, "allow", "allowed path prefix (repeatable); if none, any path is allowed")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
@@ -68,15 +158,171 @@ func main() {
|
|||||||
errLog.Fatalf("refusing to listen on non-loopback address: %s", *listen)
|
errLog.Fatalf("refusing to listen on non-loopback address: %s", *listen)
|
||||||
}
|
}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
configStore, cfgErr := newConfigStore()
|
||||||
|
if configStore == nil {
|
||||||
|
errLog.Fatalf("config init failed")
|
||||||
|
}
|
||||||
|
if cfgErr != nil {
|
||||||
|
errLog.Printf("config load error: %v", cfgErr)
|
||||||
|
}
|
||||||
|
|
||||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
mux := http.NewServeMux()
|
||||||
|
var endpointDocs []endpointDoc
|
||||||
|
|
||||||
|
register(mux, &endpointDocs, "/health", "GET", "Simple health check (JSON)", func(w http.ResponseWriter, r *http.Request) {
|
||||||
withCORS(w, r)
|
withCORS(w, r)
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "time": time.Now().Format(time.RFC3339)})
|
_ = 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) {
|
register(mux, &endpointDocs, "/info", "GET, OPTIONS", "Detailed client info (JSON)", 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", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(buildInfoPayload())
|
||||||
|
})
|
||||||
|
|
||||||
|
register(mux, &endpointDocs, "/", "GET, OPTIONS", "Human-friendly status 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
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := buildInfoPayload()
|
||||||
|
payloadJSON, _ := json.MarshalIndent(payload, "", " ")
|
||||||
|
data := struct {
|
||||||
|
Endpoints []endpointDoc
|
||||||
|
InfoJSON template.HTML
|
||||||
|
}{
|
||||||
|
Endpoints: endpointDocs,
|
||||||
|
InfoJSON: template.HTML(html.EscapeString(string(payloadJSON))),
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
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)
|
withCORS(w, r)
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
var rawPath string
|
var rawPath string
|
||||||
@@ -105,25 +351,37 @@ 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))
|
||||||
writeJSON(w, http.StatusForbidden, openResponse{OK: false, Message: "path not allowed"})
|
writeJSON(w, http.StatusForbidden, openResponse{OK: false, Message: "path not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := openfolder.OpenLocation(target); err != nil {
|
if err := openfolder.OpenLocation(target); err != nil {
|
||||||
errLog.Printf("/open open-failed method=%s path=%q normalized=%q err=%v dur=%s", r.Method, rawPath, target, err, time.Since(start))
|
errLog.Printf("/open open-failed method=%s path=%q normalized=%q err=%v dur=%s", r.Method, rawPath, target, err, time.Since(start))
|
||||||
|
notify.Show("luxtools-client", fmt.Sprintf("Failed to open: %s (%v)", target, err))
|
||||||
writeJSON(w, http.StatusInternalServerError, openResponse{OK: false, Message: err.Error()})
|
writeJSON(w, http.StatusInternalServerError, openResponse{OK: false, Message: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
infoLog.Printf("/open opened method=%s path=%q normalized=%q dur=%s", r.Method, rawPath, target, time.Since(start))
|
infoLog.Printf("/open opened method=%s path=%q normalized=%q dur=%s", r.Method, rawPath, target, time.Since(start))
|
||||||
|
if *debugNotify {
|
||||||
|
notify.Show("luxtools-client", fmt.Sprintf("Opened: %s", target))
|
||||||
|
}
|
||||||
|
|
||||||
if r.Method == http.MethodGet {
|
if r.Method == http.MethodGet {
|
||||||
// For GET callers (image-ping), a 204 avoids console noise from non-image responses.
|
// For GET callers (image-ping), a 204 avoids console noise from non-image responses.
|
||||||
@@ -222,26 +480,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")
|
||||||
@@ -259,6 +552,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