Add companion application
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
const defaultPort = 17680
|
||||
|
||||
type config struct {
|
||||
WikiRoot string `json:"wikiRoot"`
|
||||
AllowedOrigins []string `json:"allowedOrigins"`
|
||||
Port int `json:"port,omitempty"`
|
||||
}
|
||||
|
||||
// configPath returns the platform-conventional config path.
|
||||
//
|
||||
// Windows: %APPDATA%\datascape\companion.json
|
||||
// Linux: $XDG_CONFIG_HOME/datascape/companion.json
|
||||
// (fallback ~/.config/datascape/companion.json)
|
||||
func configPath() (string, error) {
|
||||
if runtime.GOOS == "windows" {
|
||||
appData := os.Getenv("APPDATA")
|
||||
if appData == "" {
|
||||
return "", errors.New("APPDATA not set")
|
||||
}
|
||||
return filepath.Join(appData, "datascape", "companion.json"), nil
|
||||
}
|
||||
if x := os.Getenv("XDG_CONFIG_HOME"); x != "" {
|
||||
return filepath.Join(x, "datascape", "companion.json"), nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".config", "datascape", "companion.json"), nil
|
||||
}
|
||||
|
||||
// loadOrInitConfig reads the on-disk config, creating a default file if none
|
||||
// exists. Returns the resolved config (with port defaulted) and the path.
|
||||
func loadOrInitConfig() (*config, string, error) {
|
||||
p, err := configPath()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
data, err := os.ReadFile(p)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
cfg := &config{AllowedOrigins: []string{}, Port: defaultPort}
|
||||
if err := writeConfigFile(p, cfg); err != nil {
|
||||
return nil, p, err
|
||||
}
|
||||
return cfg, p, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, p, err
|
||||
}
|
||||
var cfg config
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, p, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
if cfg.Port == 0 {
|
||||
cfg.Port = defaultPort
|
||||
}
|
||||
if cfg.AllowedOrigins == nil {
|
||||
cfg.AllowedOrigins = []string{}
|
||||
}
|
||||
return &cfg, p, nil
|
||||
}
|
||||
|
||||
// writeConfigFile atomically writes cfg to p (write-temp + rename).
|
||||
func writeConfigFile(p string, cfg *config) error {
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
out := *cfg
|
||||
if out.AllowedOrigins == nil {
|
||||
out.AllowedOrigins = []string{}
|
||||
}
|
||||
data, err := json.MarshalIndent(out, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := p + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, p)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>datascape companion — settings</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #2e2e2e;
|
||||
--bg-panel: #434343;
|
||||
--text: #e6e6e6;
|
||||
--text-muted: #cfcfcf;
|
||||
--primary: #87458a;
|
||||
--primary-hover: #d64d95;
|
||||
--secondary: #c48401;
|
||||
--link: #01b6c4;
|
||||
}
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 1rem ui-monospace, monospace;
|
||||
margin: 0;
|
||||
padding: 2rem 1rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
main { width: 100%; max-width: 40rem; }
|
||||
h1 { font-size: 1.2rem; margin: 0 0 0.25rem; }
|
||||
.muted { color: var(--text-muted); font-size: 0.85rem; }
|
||||
.header { border-bottom: 1px dashed var(--secondary); padding-bottom: 0.75rem; margin-bottom: 1rem; }
|
||||
label { display: block; margin: 1rem 0 0.25rem; font-size: 0.9rem; }
|
||||
input[type=text], textarea {
|
||||
width: 100%;
|
||||
background: var(--bg-panel);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--secondary);
|
||||
font: inherit;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
textarea { min-height: 6rem; resize: vertical; }
|
||||
.help { color: var(--text-muted); font-size: 0.8rem; margin-top: 0.25rem; }
|
||||
button {
|
||||
background: var(--primary);
|
||||
color: var(--text);
|
||||
border: none;
|
||||
padding: 0.6rem 1.2rem;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
button:hover { background: var(--primary-hover); }
|
||||
.notice {
|
||||
background: var(--bg-panel);
|
||||
border-left: 3px solid var(--secondary);
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.meta { margin-top: 2rem; font-size: 0.8rem; color: var(--text-muted); border-top: 1px dashed var(--secondary); padding-top: 0.75rem; }
|
||||
.meta div { margin: 0.2rem 0; }
|
||||
code { color: var(--link); word-break: break-all; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="header">
|
||||
<h1>datascape-companion</h1>
|
||||
<div class="muted">version {{.Version}} · port {{.Port}}</div>
|
||||
</div>
|
||||
|
||||
{{if .Notice}}<div class="notice">{{.Notice}}</div>{{end}}
|
||||
|
||||
<form method="POST" action="/config">
|
||||
<label for="wikiRoot">Wiki content mount path</label>
|
||||
<input id="wikiRoot" name="wikiRoot" type="text" value="{{.WikiRoot}}" placeholder="Z:\wiki or /mnt/wiki">
|
||||
<div class="help">Local filesystem path where the wiki's content tree is mounted.</div>
|
||||
|
||||
<label for="allowedOrigins">Allowed wiki origins</label>
|
||||
<textarea id="allowedOrigins" name="allowedOrigins" placeholder="https://wiki.example.lan http://192.168.1.10:8080">{{.AllowedOrigins}}</textarea>
|
||||
<div class="help">One origin per line (scheme + host + optional port, no trailing slash). Only browser tabs from these origins can ask the companion to open files.</div>
|
||||
|
||||
<button type="submit">SAVE</button>
|
||||
</form>
|
||||
|
||||
<div class="meta">
|
||||
<div>Config file: <code>{{.ConfigPath}}</code></div>
|
||||
<div>Port is set in the config file only; restart after editing.</div>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
)
|
||||
|
||||
func statPath(p string) (fs.FileInfo, error) {
|
||||
return os.Stat(p)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
)
|
||||
|
||||
const version = "0.1.0"
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
cfg, cfgPath, err := loadOrInitConfig()
|
||||
if err != nil {
|
||||
log.Fatalf("config: %v", err)
|
||||
}
|
||||
log.Printf("datascape-companion %s", version)
|
||||
log.Printf("config file: %s", cfgPath)
|
||||
|
||||
srv := newServer(cfg, cfgPath)
|
||||
log.Printf("listening on http://127.0.0.1:%d", cfg.Port)
|
||||
log.Printf("settings page: http://127.0.0.1:%d/config", cfg.Port)
|
||||
if err := srv.run(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import "os/exec"
|
||||
|
||||
// openOSPath delegates to xdg-open for both files and folders.
|
||||
func openOSPath(p string, isFolder bool) error {
|
||||
return exec.Command("xdg-open", p).Start()
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import "os/exec"
|
||||
|
||||
// openOSPath asks Windows to open a file in the default app, or a folder in
|
||||
// Explorer. `cmd /c start` handles default-handler dispatch for files; the
|
||||
// empty "" argument is the window title required by start.
|
||||
func openOSPath(p string, isFolder bool) error {
|
||||
if isFolder {
|
||||
return exec.Command("explorer.exe", p).Start()
|
||||
}
|
||||
return exec.Command("cmd", "/c", "start", "", p).Start()
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
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
|
||||
port int
|
||||
}
|
||||
|
||||
func newServer(cfg *config, cfgPath string) *server {
|
||||
return &server{cfg: cfg, cfgPath: cfgPath, 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
|
||||
}
|
||||
if err := openOSPath(resolved, isFolder); 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
|
||||
Version string
|
||||
Notice string
|
||||
}
|
||||
|
||||
func (s *server) renderConfig(w http.ResponseWriter, notice string) {
|
||||
cfg := s.snapshot()
|
||||
data := configPageData{
|
||||
WikiRoot: cfg.WikiRoot,
|
||||
AllowedOrigins: strings.Join(cfg.AllowedOrigins, "\n"),
|
||||
Port: cfg.Port,
|
||||
ConfigPath: s.cfgPath,
|
||||
Version: version,
|
||||
Notice: notice,
|
||||
}
|
||||
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{}
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
newCfg := *s.cfg
|
||||
newCfg.WikiRoot = wikiRoot
|
||||
newCfg.AllowedOrigins = origins
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user