package main import ( "embed" "encoding/json" "errors" "fmt" "html/template" "net/http" "path/filepath" "strings" "sync" ) //go:embed config.html var templates embed.FS var configTmpl = template.Must(template.ParseFS(templates, "config.html")) type server struct { mu sync.Mutex cfg *config cfgPath string logPath string port int } func newServer(cfg *config, cfgPath, logPath string) *server { return &server{cfg: cfg, cfgPath: cfgPath, logPath: logPath, port: cfg.Port} } func (s *server) snapshot() config { s.mu.Lock() defer s.mu.Unlock() c := *s.cfg c.AllowedOrigins = append([]string(nil), s.cfg.AllowedOrigins...) return c } func (s *server) run() error { mux := http.NewServeMux() mux.HandleFunc("/status", s.handleStatus) mux.HandleFunc("/open-file", s.handleOpenFile) mux.HandleFunc("/open-folder", s.handleOpenFolder) mux.HandleFunc("/config", s.handleConfig) addr := fmt.Sprintf("127.0.0.1:%d", s.port) return http.ListenAndServe(addr, mux) } // methodAllowed enforces a single allowed method, sending 405 otherwise. func methodAllowed(w http.ResponseWriter, r *http.Request, method string) bool { if r.Method == method { return true } w.Header().Set("Allow", method) http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return false } // requireAllowedOrigin checks the Origin header against the allowlist. // Sets the matching CORS header on success. Returns false (and writes a // 403) when no match is found. func (s *server) requireAllowedOrigin(w http.ResponseWriter, r *http.Request) bool { origin := r.Header.Get("Origin") if origin == "" { http.Error(w, "origin required", http.StatusForbidden) return false } cfg := s.snapshot() for _, allowed := range cfg.AllowedOrigins { if origin == allowed { w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Vary", "Origin") return true } } http.Error(w, "origin not allowed", http.StatusForbidden) return false } func (s *server) handleStatus(w http.ResponseWriter, r *http.Request) { if !methodAllowed(w, r, http.MethodGet) { return } if !s.requireAllowedOrigin(w, r) { return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{ "name": "datascape-companion", "version": version, }) } func (s *server) handleOpenFile(w http.ResponseWriter, r *http.Request) { s.handleOpen(w, r, false) } func (s *server) handleOpenFolder(w http.ResponseWriter, r *http.Request) { s.handleOpen(w, r, true) } func (s *server) handleOpen(w http.ResponseWriter, r *http.Request, isFolder bool) { if !methodAllowed(w, r, http.MethodGet) { return } if !s.requireAllowedOrigin(w, r) { return } wikiPath := r.URL.Query().Get("path") cfg := s.snapshot() if cfg.WikiRoot == "" { writeJSONError(w, http.StatusBadRequest, "wikiRoot is not configured") return } resolved, err := resolveWikiPath(cfg.WikiRoot, wikiPath) if err != nil { writeJSONError(w, http.StatusBadRequest, err.Error()) return } info, err := statPath(resolved) if err != nil { writeJSONError(w, http.StatusNotFound, "path not found") return } if isFolder && !info.IsDir() { // Reveal: if the user asked to open a file's folder, walk up. resolved = filepath.Dir(resolved) } if !isFolder && info.IsDir() { writeJSONError(w, http.StatusBadRequest, "path is a directory") return } defs := defaultCommands() var template string if isFolder { template = resolveOpenCommand(cfg.OpenFolderCommand, defs.OpenFolder) } else { template = resolveOpenCommand(cfg.OpenFileCommand, defs.OpenFile) } if err := runOpenCommand(template, resolved); err != nil { writeJSONError(w, http.StatusInternalServerError, err.Error()) return } w.WriteHeader(http.StatusNoContent) } func (s *server) handleConfig(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: s.renderConfig(w, "") case http.MethodPost: s.saveConfig(w, r) default: w.Header().Set("Allow", "GET, POST") http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } type configPageData struct { WikiRoot string AllowedOrigins string Port int ConfigPath string LogPath string LogTail string LogError string Version string Notice string OpenFileCommand string OpenFolderCommand string DefaultOpenFileCommand string DefaultOpenFolderCommand string } func (s *server) renderConfig(w http.ResponseWriter, notice string) { cfg := s.snapshot() defs := defaultCommands() data := configPageData{ WikiRoot: cfg.WikiRoot, AllowedOrigins: strings.Join(cfg.AllowedOrigins, "\n"), Port: cfg.Port, ConfigPath: s.cfgPath, LogPath: s.logPath, Version: version, Notice: notice, OpenFileCommand: cfg.OpenFileCommand, OpenFolderCommand: cfg.OpenFolderCommand, DefaultOpenFileCommand: defs.OpenFile, DefaultOpenFolderCommand: defs.OpenFolder, } if s.logPath != "" { lines, err := tailLog(s.logPath, 50) if err != nil { data.LogError = err.Error() } else { data.LogTail = strings.Join(lines, "\n") } } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := configTmpl.Execute(w, data); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } func (s *server) saveConfig(w http.ResponseWriter, r *http.Request) { expected := fmt.Sprintf("http://127.0.0.1:%d", s.port) if r.Header.Get("Origin") != expected { http.Error(w, "origin mismatch", http.StatusForbidden) return } if err := r.ParseForm(); err != nil { http.Error(w, "bad form", http.StatusBadRequest) return } wikiRoot := strings.TrimSpace(r.FormValue("wikiRoot")) originsRaw := r.FormValue("allowedOrigins") var origins []string for _, line := range strings.Split(originsRaw, "\n") { line = strings.TrimSpace(line) line = strings.TrimRight(line, "/") if line == "" { continue } origins = append(origins, line) } if origins == nil { origins = []string{} } openFileCmd := strings.TrimSpace(r.FormValue("openFileCommand")) openFolderCmd := strings.TrimSpace(r.FormValue("openFolderCommand")) defs := defaultCommands() // Persist as blank when the user submits the default verbatim, so the // config file stays clean and future default changes propagate. if openFileCmd == defs.OpenFile { openFileCmd = "" } if openFolderCmd == defs.OpenFolder { openFolderCmd = "" } s.mu.Lock() newCfg := *s.cfg newCfg.WikiRoot = wikiRoot newCfg.AllowedOrigins = origins newCfg.OpenFileCommand = openFileCmd newCfg.OpenFolderCommand = openFolderCmd if err := writeConfigFile(s.cfgPath, &newCfg); err != nil { s.mu.Unlock() http.Error(w, "save failed: "+err.Error(), http.StatusInternalServerError) return } s.cfg = &newCfg s.mu.Unlock() s.renderConfig(w, "Saved.") } func writeJSONError(w http.ResponseWriter, code int, msg string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) } // resolveWikiPath joins wikiRoot with a wiki-relative path after rejecting // absolute paths, traversal segments, and null bytes. The cleaned result // must remain inside wikiRoot. func resolveWikiPath(wikiRoot, wikiPath string) (string, error) { if strings.ContainsRune(wikiPath, 0) { return "", errors.New("invalid path") } // Reject absolute paths from either family before any cleaning so we // don't depend on filepath.IsAbs's per-OS behavior. if strings.HasPrefix(wikiPath, "/") || strings.HasPrefix(wikiPath, `\`) || (len(wikiPath) >= 2 && wikiPath[1] == ':') { return "", errors.New("absolute path not allowed") } clean := filepath.Clean(filepath.FromSlash(wikiPath)) if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { return "", errors.New("path escapes wikiRoot") } rootAbs, err := filepath.Abs(wikiRoot) if err != nil { return "", err } full := filepath.Join(rootAbs, clean) rel, err := filepath.Rel(rootAbs, full) if err != nil { return "", err } if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { return "", errors.New("path escapes wikiRoot") } return full, nil }