582 lines
16 KiB
Go
582 lines
16 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"html"
|
|
"html/template"
|
|
"io"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"runtime/debug"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"luxtools-client/internal/config"
|
|
"luxtools-client/internal/installer"
|
|
"luxtools-client/internal/notify"
|
|
"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
|
|
|
|
func (a *allowList) String() string { return strings.Join(*a, ",") }
|
|
func (a *allowList) Set(value string) error {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return nil
|
|
}
|
|
*a = append(*a, value)
|
|
return nil
|
|
}
|
|
|
|
type openRequest struct {
|
|
Path string `json:"path"`
|
|
}
|
|
|
|
type openResponse struct {
|
|
OK bool `json:"ok"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
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() {
|
|
infoLog := log.New(os.Stdout, "", log.LstdFlags)
|
|
errLog := log.New(os.Stderr, "ERROR: ", log.LstdFlags)
|
|
|
|
if len(os.Args) > 1 {
|
|
switch os.Args[1] {
|
|
case "install":
|
|
if err := runInstall(os.Args[2:], infoLog, errLog); err != nil {
|
|
errLog.Fatal(err)
|
|
}
|
|
return
|
|
case "uninstall":
|
|
if err := runUninstall(os.Args[2:], infoLog, errLog); err != nil {
|
|
errLog.Fatal(err)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
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
|
|
flag.Var(&allowed, "allow", "allowed path prefix (repeatable); if none, any path is allowed")
|
|
flag.Parse()
|
|
|
|
if !isLoopbackListenAddr(*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()
|
|
var endpointDocs []endpointDoc
|
|
|
|
register(mux, &endpointDocs, "/health", "GET", "Simple health check (JSON)", func(w http.ResponseWriter, r *http.Request) {
|
|
withCORS(w, r)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "time": time.Now().Format(time.RFC3339)})
|
|
})
|
|
|
|
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, "/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()
|
|
var rawPath string
|
|
if r.Method == http.MethodOptions {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
|
|
var req openRequest
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
req.Path = r.URL.Query().Get("path")
|
|
case http.MethodPost:
|
|
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32*1024))
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(&req); err != nil {
|
|
errLog.Printf("/open bad-json method=%s err=%v dur=%s", r.Method, err, time.Since(start))
|
|
writeJSON(w, http.StatusBadRequest, openResponse{OK: false, Message: "invalid json"})
|
|
return
|
|
}
|
|
default:
|
|
errLog.Printf("/open method-not-allowed method=%s dur=%s", r.Method, time.Since(start))
|
|
writeJSON(w, http.StatusMethodNotAllowed, openResponse{OK: false, Message: "GET or POST required"})
|
|
return
|
|
}
|
|
|
|
rawPath = 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))
|
|
writeJSON(w, http.StatusForbidden, openResponse{OK: false, Message: "path not allowed"})
|
|
return
|
|
}
|
|
|
|
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))
|
|
notify.Show("luxtools-client", fmt.Sprintf("Failed to open: %s (%v)", target, err))
|
|
writeJSON(w, http.StatusInternalServerError, openResponse{OK: false, Message: err.Error()})
|
|
return
|
|
}
|
|
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 {
|
|
// For GET callers (image-ping), a 204 avoids console noise from non-image responses.
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, openResponse{OK: true, Message: "opened"})
|
|
})
|
|
|
|
srv := &http.Server{
|
|
Addr: *listen,
|
|
Handler: mux,
|
|
ReadHeaderTimeout: 5 * time.Second,
|
|
ReadTimeout: 10 * time.Second,
|
|
WriteTimeout: 10 * time.Second,
|
|
IdleTimeout: 30 * time.Second,
|
|
}
|
|
|
|
infoLog.Printf("listening on http://%s", *listen)
|
|
infoLog.Printf("os=%s arch=%s", runtime.GOOS, runtime.GOARCH)
|
|
errLog.Fatal(srv.ListenAndServe())
|
|
}
|
|
|
|
func runInstall(args []string, infoLog, errLog *log.Logger) error {
|
|
fs := flag.NewFlagSet("install", flag.ContinueOnError)
|
|
fs.SetOutput(io.Discard)
|
|
|
|
listen := fs.String("listen", "127.0.0.1:8765", "listen address (host:port), should be loopback")
|
|
dryRun := fs.Bool("dry-run", false, "print/validate only; do not write files or register services")
|
|
var allowed allowList
|
|
fs.Var(&allowed, "allow", "allowed path prefix (repeatable); if none, any path is allowed")
|
|
if err := fs.Parse(args); err != nil {
|
|
return fmt.Errorf("install: %w", err)
|
|
}
|
|
|
|
if !installer.Supported() {
|
|
return fmt.Errorf("install: unsupported OS: %s", runtime.GOOS)
|
|
}
|
|
if !isLoopbackListenAddr(*listen) {
|
|
return fmt.Errorf("install: refusing non-loopback listen address: %s", *listen)
|
|
}
|
|
|
|
if err := installer.Install(installer.InstallOptions{Listen: *listen, Allow: []string(allowed), DryRun: *dryRun}); err != nil {
|
|
return err
|
|
}
|
|
if *dryRun {
|
|
infoLog.Printf("install dry-run OK")
|
|
return nil
|
|
}
|
|
infoLog.Printf("installed %s", installer.ServiceName)
|
|
return nil
|
|
}
|
|
|
|
func runUninstall(args []string, infoLog, errLog *log.Logger) error {
|
|
fs := flag.NewFlagSet("uninstall", flag.ContinueOnError)
|
|
fs.SetOutput(io.Discard)
|
|
|
|
keepConfig := fs.Bool("keep-config", false, "keep config on disk")
|
|
dryRun := fs.Bool("dry-run", false, "print/validate only; do not remove files or unregister services")
|
|
if err := fs.Parse(args); err != nil {
|
|
return fmt.Errorf("uninstall: %w", err)
|
|
}
|
|
|
|
if !installer.Supported() {
|
|
return fmt.Errorf("uninstall: unsupported OS: %s", runtime.GOOS)
|
|
}
|
|
if err := installer.Uninstall(installer.UninstallOptions{KeepConfig: *keepConfig, DryRun: *dryRun}); err != nil {
|
|
return err
|
|
}
|
|
if *dryRun {
|
|
infoLog.Printf("uninstall dry-run OK")
|
|
return nil
|
|
}
|
|
infoLog.Printf("uninstalled %s", installer.ServiceName)
|
|
return nil
|
|
}
|
|
|
|
func isLoopbackListenAddr(addr string) bool {
|
|
host, _, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
ip := net.ParseIP(host)
|
|
if ip == nil {
|
|
return host == "localhost"
|
|
}
|
|
return ip.IsLoopback()
|
|
}
|
|
|
|
func withCORS(w http.ResponseWriter, r *http.Request) {
|
|
origin := r.Header.Get("Origin")
|
|
if origin != "" {
|
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
|
w.Header().Set("Vary", "Origin")
|
|
} else {
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
}
|
|
w.Header().Set("Access-Control-Allow-Methods", "POST, GET, 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")
|
|
}
|
|
|
|
p = filepath.Clean(p)
|
|
if !filepath.IsAbs(p) {
|
|
return "", errors.New("path must be absolute")
|
|
}
|
|
|
|
// Ensure path exists.
|
|
st, err := os.Stat(p)
|
|
if err != nil {
|
|
return "", fmt.Errorf("path not found")
|
|
}
|
|
if !st.IsDir() {
|
|
// If a file is provided, open its containing folder.
|
|
p = filepath.Dir(p)
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
func 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 {
|
|
a = filepath.Clean(a)
|
|
if a == "." || a == string(os.PathSeparator) {
|
|
return true
|
|
}
|
|
// Case-insensitive on Windows.
|
|
if runtime.GOOS == "windows" {
|
|
if strings.HasPrefix(strings.ToLower(path), strings.ToLower(a)) {
|
|
return true
|
|
}
|
|
} else {
|
|
if strings.HasPrefix(path, a) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, resp openResponse) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
}
|