Add companion application

This commit is contained in:
2026-05-08 20:47:02 +02:00
parent 5fcca77d58
commit 7209aebc62
15 changed files with 802 additions and 3 deletions
+92
View File
@@ -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)
}
+90
View File
@@ -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&#10;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>
+10
View File
@@ -0,0 +1,10 @@
package main
import (
"io/fs"
"os"
)
func statPath(p string) (fs.FileInfo, error) {
return os.Stat(p)
}
+26
View File
@@ -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)
}
}
+10
View File
@@ -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()
}
+15
View File
@@ -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()
}
+254
View File
@@ -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
}