Files
datascape/cmd/companion/server.go
T

298 lines
8.3 KiB
Go

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
}