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
+2
View File
@@ -6,3 +6,5 @@ cache/
# Binaries
datascape
*.exe
bin/
companion/datascape-companion-*
+24 -2
View File
@@ -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
+176
View File
@@ -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();
}
})();
+5
View File
@@ -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>
+18
View File
@@ -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();
})();
+3 -1
View File
@@ -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>
+24
View File
@@ -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);
+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
}
+53
View File
@@ -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)
}
}