Add companion application
This commit is contained in:
@@ -6,3 +6,5 @@ cache/
|
||||
# Binaries
|
||||
datascape
|
||||
*.exe
|
||||
bin/
|
||||
companion/datascape-companion-*
|
||||
|
||||
@@ -1,7 +1,29 @@
|
||||
NAS := luxick@192.168.3.3
|
||||
|
||||
.PHONY: deploy
|
||||
deploy:
|
||||
COMPANION_WIN := companion/datascape-companion-windows-amd64.exe
|
||||
COMPANION_LIN := companion/datascape-companion-linux-amd64
|
||||
|
||||
.PHONY: deploy companion companion-windows companion-linux companion-release
|
||||
|
||||
# Cross-compiled companion artifacts the wiki binary embeds. Both must exist
|
||||
# before `go build .` so embed.FS picks them up.
|
||||
companion-release: $(COMPANION_WIN) $(COMPANION_LIN)
|
||||
|
||||
$(COMPANION_WIN):
|
||||
GOOS=windows GOARCH=amd64 go build -o $@ ./cmd/companion
|
||||
|
||||
$(COMPANION_LIN):
|
||||
GOOS=linux GOARCH=amd64 go build -o $@ ./cmd/companion
|
||||
|
||||
companion-windows: $(COMPANION_WIN)
|
||||
companion-linux: $(COMPANION_LIN)
|
||||
|
||||
# Local companion build for the host OS (handy for development).
|
||||
companion:
|
||||
mkdir -p bin
|
||||
go build -o bin/ ./cmd/companion
|
||||
|
||||
deploy: companion-release
|
||||
GOOS=linux GOARCH=arm GOARM=7 go build -o datascape-arm .
|
||||
ssh $(NAS) 'kill $$(cat /share/homes/luxick/.local/bin/datascape.pid) 2>/dev/null; rm -f /share/homes/luxick/.local/bin/datascape.pid'
|
||||
scp datascape-arm $(NAS):/share/homes/luxick/.local/bin/datascape
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
// Detects the local datascape-companion via a /status probe and wires up
|
||||
// the footer status icon, file-row click interception, and the "reveal in
|
||||
// file manager" page action. All companion calls are best-effort: if the
|
||||
// fetch fails the page falls back to default browser behavior.
|
||||
|
||||
(function () {
|
||||
var COMPANION_PORT = 17680;
|
||||
var COMPANION_BASE = 'http://127.0.0.1:' + COMPANION_PORT;
|
||||
var STATUS_TIMEOUT_MS = 1500;
|
||||
|
||||
var state = { available: false, info: null };
|
||||
|
||||
function wikiPathFromHref(href) {
|
||||
// href is the URL the wiki rendered for the listing item (e.g.
|
||||
// "/photos/2024/img.jpg"). Strip leading slash and decode so the
|
||||
// companion sees a relative wiki path matching its on-disk layout.
|
||||
try {
|
||||
var u = new URL(href, window.location.href);
|
||||
if (u.origin !== window.location.origin) return null;
|
||||
var p = u.pathname.replace(/^\/+/, '');
|
||||
return decodeURIComponent(p);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function companionGET(path, params) {
|
||||
var qs = '';
|
||||
if (params) {
|
||||
var parts = [];
|
||||
for (var k in params) {
|
||||
parts.push(encodeURIComponent(k) + '=' + encodeURIComponent(params[k]));
|
||||
}
|
||||
qs = '?' + parts.join('&');
|
||||
}
|
||||
var ctrl = new AbortController();
|
||||
var timer = setTimeout(function () { ctrl.abort(); }, STATUS_TIMEOUT_MS);
|
||||
return fetch(COMPANION_BASE + path + qs, {
|
||||
method: 'GET',
|
||||
mode: 'cors',
|
||||
credentials: 'omit',
|
||||
signal: ctrl.signal
|
||||
}).finally(function () { clearTimeout(timer); });
|
||||
}
|
||||
|
||||
function renderFlyout(menu) {
|
||||
menu.innerHTML = '';
|
||||
if (state.available) {
|
||||
var info = state.info || {};
|
||||
var name = info.name || 'datascape-companion';
|
||||
var ver = info.version ? ' v' + info.version : '';
|
||||
var head = document.createElement('div');
|
||||
head.className = 'panel-header';
|
||||
head.textContent = 'Companion';
|
||||
menu.appendChild(head);
|
||||
|
||||
var label = document.createElement('div');
|
||||
label.className = 'companion-line';
|
||||
label.textContent = name + ver;
|
||||
menu.appendChild(label);
|
||||
|
||||
var link = document.createElement('a');
|
||||
link.className = 'btn dropdown-item';
|
||||
link.href = COMPANION_BASE + '/config';
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener';
|
||||
link.textContent = 'Settings';
|
||||
menu.appendChild(link);
|
||||
} else {
|
||||
var head2 = document.createElement('div');
|
||||
head2.className = 'panel-header';
|
||||
head2.textContent = 'Companion not detected';
|
||||
menu.appendChild(head2);
|
||||
|
||||
var msg = document.createElement('div');
|
||||
msg.className = 'companion-line muted';
|
||||
msg.textContent = 'Install the companion to open files locally.';
|
||||
menu.appendChild(msg);
|
||||
|
||||
var win = document.createElement('a');
|
||||
win.className = 'btn dropdown-item';
|
||||
win.href = '/companion/download/windows';
|
||||
win.textContent = 'Download — Windows';
|
||||
menu.appendChild(win);
|
||||
|
||||
var lin = document.createElement('a');
|
||||
lin.className = 'btn dropdown-item';
|
||||
lin.href = '/companion/download/linux';
|
||||
lin.textContent = 'Download — Linux';
|
||||
menu.appendChild(lin);
|
||||
}
|
||||
}
|
||||
|
||||
function updateFooterIcon() {
|
||||
var wrap = document.querySelector('[data-companion-status]');
|
||||
if (!wrap) return;
|
||||
wrap.hidden = false;
|
||||
var btn = wrap.querySelector('.companion-icon');
|
||||
if (state.available) {
|
||||
btn.textContent = '●';
|
||||
btn.classList.add('companion-on');
|
||||
btn.classList.remove('companion-off');
|
||||
btn.title = 'Companion detected';
|
||||
} else {
|
||||
btn.textContent = '○';
|
||||
btn.classList.add('companion-off');
|
||||
btn.classList.remove('companion-on');
|
||||
btn.title = 'Companion not detected';
|
||||
}
|
||||
var menu = wrap.querySelector('.companion-flyout');
|
||||
renderFlyout(menu);
|
||||
if (typeof wireDropdown === 'function') wireDropdown(btn);
|
||||
}
|
||||
|
||||
function wireFileLinks() {
|
||||
if (!state.available) return;
|
||||
document.addEventListener('click', function (e) {
|
||||
var item = e.target.closest && e.target.closest('.listing-item');
|
||||
if (!item) return;
|
||||
var anchor = e.target.closest('a');
|
||||
if (!anchor) return;
|
||||
// Only intercept the primary file link, and only for files (not folders).
|
||||
// Folders end with "/" — let the browser navigate normally.
|
||||
var path = item.dataset.path || anchor.getAttribute('href');
|
||||
if (!path || path.endsWith('/')) return;
|
||||
// Allow modified clicks (open in new tab, etc.) to pass through.
|
||||
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||
var rel = wikiPathFromHref(path);
|
||||
if (rel === null) return;
|
||||
e.preventDefault();
|
||||
companionGET('/open-file', { path: rel }).catch(function () {
|
||||
// Fallback: navigate to the file (download / inline view).
|
||||
window.location.href = anchor.href;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function wireRevealButton() {
|
||||
var btn = document.querySelector('[data-companion-reveal]');
|
||||
if (!btn) return;
|
||||
if (!state.available) return;
|
||||
btn.hidden = false;
|
||||
btn.addEventListener('click', function () {
|
||||
var rel = wikiPathFromHref(window.location.pathname);
|
||||
if (rel === null) rel = '';
|
||||
companionGET('/open-folder', { path: rel }).catch(function () { });
|
||||
});
|
||||
}
|
||||
|
||||
function probeStatus() {
|
||||
return companionGET('/status').then(function (r) {
|
||||
if (!r.ok) throw new Error('status ' + r.status);
|
||||
return r.json();
|
||||
}).then(function (info) {
|
||||
state.available = true;
|
||||
state.info = info;
|
||||
}).catch(function () {
|
||||
state.available = false;
|
||||
state.info = null;
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
probeStatus().then(function () {
|
||||
updateFooterIcon();
|
||||
wireFileLinks();
|
||||
wireRevealButton();
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
@@ -11,6 +11,7 @@
|
||||
<script src="/_/modal.js"></script>
|
||||
<script src="/_/global-shortcuts.js"></script>
|
||||
<script src="/_/tree-picker.js"></script>
|
||||
<script src="/_/companion.js" defer></script>
|
||||
{{block "headScripts" .}}{{end}}
|
||||
</head>
|
||||
<body>
|
||||
@@ -34,6 +35,10 @@
|
||||
<footer>
|
||||
<span class="muted">Request: {{.RenderMS}} ms</span>
|
||||
{{block "footerExtras" .}}{{end}}
|
||||
<span class="dropdown companion-status" data-companion-status hidden>
|
||||
<button type="button" class="btn btn-small companion-icon" data-action="companion-toggle" title="Companion status" aria-label="Companion status">○</button>
|
||||
<div class="dropdown-menu align-right open-up companion-flyout"></div>
|
||||
</span>
|
||||
</footer>
|
||||
{{block "extras" .}}{{end}}
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// Lift the floating action button above the footer when the footer is on
|
||||
// screen, so it never overlaps the request-time line or the companion icon.
|
||||
// Mirrors the TOC's header-aware top-offset behaviour in toc.js.
|
||||
(function () {
|
||||
var fab = document.querySelector(".fab");
|
||||
var footer = document.querySelector("footer");
|
||||
if (!fab || !footer) return;
|
||||
|
||||
function updateBottom() {
|
||||
var rect = footer.getBoundingClientRect();
|
||||
var overlap = Math.max(0, window.innerHeight - rect.top);
|
||||
fab.style.bottom = (overlap + 16) + "px";
|
||||
}
|
||||
|
||||
window.addEventListener("scroll", updateBottom, { passive: true });
|
||||
window.addEventListener("resize", updateBottom);
|
||||
updateBottom();
|
||||
})();
|
||||
@@ -11,7 +11,7 @@
|
||||
<h2 id="files">Files</h2>
|
||||
<div class="listing">
|
||||
{{range .Entries}}
|
||||
<div class="listing-item">
|
||||
<div class="listing-item" data-path="{{.URL}}">
|
||||
<span class="icon">{{.Icon}}</span>
|
||||
<a href="{{.URL}}">{{.Name}}</a>
|
||||
<span class="meta">{{.Meta}}</span>
|
||||
@@ -37,11 +37,13 @@
|
||||
{{define "extras"}}
|
||||
{{if .SidebarWidget}}{{.SidebarWidget}}{{end}}
|
||||
{{if .CanEdit}}
|
||||
<script src="/_/page/fab.js" defer></script>
|
||||
<div class="fab dropdown">
|
||||
<button class="btn btn-fab" data-action="actions-drop" title="Actions" aria-label="Actions">≡</button>
|
||||
<div class="dropdown-menu align-right open-up">
|
||||
<button class="btn dropdown-item" onclick="newPage()" title="New page (N)">NEW</button>
|
||||
<a class="btn dropdown-item" href="?edit" title="Edit page (E)">EDIT</a>
|
||||
<button class="btn dropdown-item" data-companion-reveal hidden title="Reveal in file manager">REVEAL</button>
|
||||
{{if not .IsRoot}}
|
||||
<button class="btn dropdown-item" onclick="movePage()" title="Move page (M)">MOVE</button>
|
||||
<button class="btn dropdown-item danger" onclick="deletePage()" title="Delete page">DELETE</button>
|
||||
|
||||
@@ -462,6 +462,30 @@ footer {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* === Companion status === */
|
||||
.companion-status {
|
||||
margin-left: auto;
|
||||
}
|
||||
.companion-icon {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
padding: 0.1rem 0.3rem;
|
||||
}
|
||||
.companion-on {
|
||||
color: var(--link);
|
||||
}
|
||||
.companion-off {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.companion-flyout {
|
||||
min-width: 14rem;
|
||||
padding: 0.4rem;
|
||||
}
|
||||
.companion-line {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* === Task lists === */
|
||||
.content li:has(> input.task-checkbox:checked) {
|
||||
color: var(--text-muted);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Cross-compiled companion binaries served as downloads from the wiki footer
|
||||
// flyout when no local companion is detected. The Makefile produces these
|
||||
// before invoking `go build .`; missing files will fail the build at compile
|
||||
// time via the embed directive below.
|
||||
//
|
||||
//go:embed companion/datascape-companion-windows-amd64.exe
|
||||
//go:embed companion/datascape-companion-linux-amd64
|
||||
var companionBinaries embed.FS
|
||||
|
||||
func init() {
|
||||
http.HandleFunc("/companion/download/windows", serveCompanionBinary(
|
||||
"companion/datascape-companion-windows-amd64.exe",
|
||||
"datascape-companion.exe",
|
||||
))
|
||||
http.HandleFunc("/companion/download/linux", serveCompanionBinary(
|
||||
"companion/datascape-companion-linux-amd64",
|
||||
"datascape-companion",
|
||||
))
|
||||
}
|
||||
|
||||
func serveCompanionBinary(embedPath, downloadName string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.Header().Set("Allow", http.MethodGet)
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
f, err := companionBinaries.Open(embedPath)
|
||||
if err != nil {
|
||||
http.Error(w, "companion binary not available", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
http.Error(w, "companion binary not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+downloadName+`"`)
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(info.Size(), 10))
|
||||
_, _ = io.Copy(w, f)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user