Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 19 additions & 19 deletions .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,28 +1,28 @@
FROM debian:bookworm-slim
# Prebuilt VS Code devcontainer base with Node.js, npm, git, a non-root
# "node" user, and passwordless sudo already baked in. Using this image
# instead of layering the `node` devcontainer feature means the build
# never fetches node/npm/pnpm/yarn from the public npm registry — the
# toolchain ships in the image layers (pulled from MCR). Go is added via
# the `go` feature (see devcontainer.json); it installs from go.dev, not npm.
FROM mcr.microsoft.com/devcontainers/javascript-node:22-bookworm

ARG DEBIAN_FRONTEND=noninteractive

# System packages that don't come from npm:
# - git-lfs: wiki sync routes binary assets through LFS on providers
# that support it.
# - chromium (+ fonts): headless PDF export drives Chromium via chromedp,
# which finds it on PATH as `chromium`. Pulled from the Debian repos,
# so the build needs no npm registry access at all. Fonts keep PDF and
# Mermaid rendering legible.
# - netcat-traditional: used by dev tooling / health probes.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
curl \
wget \
pkg-config \
libssl-dev \
ca-certificates \
git \
git-lfs \
chromium \
fonts-liberation \
fonts-noto-core \
netcat-traditional \
sudo \
&& git lfs install \
&& git lfs install --system \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*

# Create non-root user
ARG USERNAME=vscode
ARG USER_UID=1000
ARG USER_GID=$USER_UID
RUN groupadd --force --gid $USER_GID $USERNAME \
&& useradd --uid $USER_UID --gid $USER_GID -m $USERNAME -s /bin/bash || true \
&& echo "$USERNAME ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers.d/$USERNAME \
&& chmod 0440 /etc/sudoers.d/$USERNAME
29 changes: 12 additions & 17 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,14 @@
"build": {
"dockerfile": "Dockerfile"
},
"remoteUser": "vscode",
"remoteUser": "node",
"features": {
"ghcr.io/devcontainers/features/go:1": {
"version": "latest",
"golangciLintVersion": "1.64.8"
},
"ghcr.io/devcontainers/features/node:1": {
"version": "lts"
},
"ghcr.io/devcontainers/features/sshd:1": {
"version": "latest"
},
// Playwright + browsers + Linux runtime libs in one shot. The
// feature's install.sh runs `npx playwright install --with-deps`
// as the remote user, so the browser binaries land in
// /home/vscode/.cache/ms-playwright/ and the apt-side runtime
// libs (libnss3, libgbm1, etc.) are pulled in too. Reproducible
// and dramatically simpler than maintaining the dep list in our
// Dockerfile.
"ghcr.io/schlich/devcontainer-features/playwright:0": {
"browsers": "chromium"
}
},
// Runs on the HOST before the container is created/started. Picks a
Expand All @@ -34,7 +21,15 @@
// [[preferences/devcontainer-ports]] for the full rationale and the
// consumer recipes (launch.json, tasks.json, host scripts).
"initializeCommand": ".devcontainer/initializeCommand.sh",
"postCreateCommand": "git config --global --add safe.directory /workspaces/mind-map && go version && node --version && { CHROME=$(find ~/.cache/ms-playwright/chromium-*/chrome-linux/chrome 2>/dev/null | head -1) && [ -n \"$CHROME\" ] && sudo ln -sf \"$CHROME\" /usr/local/bin/chromium || true; }",
// NPM_CONFIG_REGISTRY is passed through from the host environment so a
// machine behind a corporate proxy (whose npm registry differs from the
// public one) can still `npm install` at postAttach. It references npm's
// own standard variable — never a hardcoded URL — so it's empty and
// harmless on a public/CI machine, where npm uses its default registry.
"remoteEnv": {
"NPM_CONFIG_REGISTRY": "${localEnv:NPM_CONFIG_REGISTRY}"
},
"postCreateCommand": "git config --global --add safe.directory /workspaces/mind-map && go version && node --version && chromium --version",
"postAttachCommand": "npm install --prefix webui",
"customizations": {
"vscode": {
Expand Down Expand Up @@ -77,11 +72,11 @@
}
},
"mounts": [
// Bind-mount the workspace's .mind-map/ as the vscode user's home
// Bind-mount the workspace's .mind-map/ as the node user's home
// .mind-map/ so the committed config.json (with the pull-only sync
// mapping to the GitHub wiki) is what mind-map serve reads. The
// wiki dir itself (.mind-map/wiki/) is gitignored and populated by
// sync on first launch.
"source=${localWorkspaceFolder}/.mind-map,target=/home/vscode/.mind-map,type=bind,consistency=cached"
"source=${localWorkspaceFolder}/.mind-map,target=/home/node/.mind-map,type=bind,consistency=cached"
]
}
68 changes: 66 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ type SyncMapping struct {
Prefix string `json:"prefix"`
Remote string `json:"remote"`
Direction SyncDirection `json:"direction,omitempty"`
// Token is an optional personal access token used to authenticate
// HTTPS git operations for this mapping's remote. When empty, the
// SyncConfig-level Token is used; when that's empty too, sync falls
// back to the machine's existing git credentials (helpers, keychain,
// gh, SSH). Injected via GIT_ASKPASS so it never lands in the shadow
// clone's .git/config or a process argument. Redacted by the settings
// API — it is never echoed back to the browser.
Token string `json:"token,omitempty"`
// LFS, when true, configures the synced shadow clone to track
// the patterns in LFSPatterns via git-lfs. Useful when binary
// assets (uploaded via the image-support tools) would otherwise
Expand Down Expand Up @@ -69,12 +77,35 @@ func DefaultLFSPatterns() []string {

// SyncConfig holds git sync settings.
type SyncConfig struct {
Enabled bool `json:"enabled"`
Default string `json:"default"`
Enabled bool `json:"enabled"`
Default string `json:"default"`
// Token is the default personal access token used to authenticate
// HTTPS git operations for any remote that doesn't set its own
// per-mapping Token. Empty means "use the machine's existing git
// credentials" (the historical behavior). Injected via GIT_ASKPASS,
// and redacted by the settings API — never echoed to the browser.
Token string `json:"token,omitempty"`
Interval string `json:"interval"`
Mappings []SyncMapping `json:"mappings,omitempty"`
}

// TokenForRemote returns the access token to use for the given remote.
// A non-empty token on any mapping for that remote wins (first match);
// otherwise the SyncConfig-level default Token is used. Returns "" when
// no token is configured, which the sync engine treats as "fall back to
// the machine's existing git credentials".
func (s *SyncConfig) TokenForRemote(remote string) string {
if remote == "" {
return ""
}
for _, m := range s.Mappings {
if m.Remote == remote && m.Token != "" {
return m.Token
}
}
return s.Token
}

// ParseInterval returns the sync interval as a time.Duration.
// Returns the default (30s) if the value is empty or invalid.
func (s *SyncConfig) ParseInterval() time.Duration {
Expand Down Expand Up @@ -156,6 +187,39 @@ func (s *SyncConfig) AddMappingWithLFS(prefix, remote string, direction SyncDire
})
}

// AddMappingFull is the superset of AddMappingWithLFS that also manages
// the per-mapping access Token. Remote, direction, and LFS settings are
// replaced on every call (a re-registration). The token is only
// overwritten when a non-empty token is supplied; passing "" preserves
// any existing token so a re-registration that omits the token doesn't
// silently drop stored credentials.
func (s *SyncConfig) AddMappingFull(prefix, remote string, direction SyncDirection, lfs bool, patterns []string, token string) {
direction = direction.Normalize()
if lfs && patterns == nil {
patterns = DefaultLFSPatterns()
}
for i, m := range s.Mappings {
if m.Prefix == prefix {
s.Mappings[i].Remote = remote
s.Mappings[i].Direction = direction
s.Mappings[i].LFS = lfs
s.Mappings[i].LFSPatterns = patterns
if token != "" {
s.Mappings[i].Token = token
}
return
}
}
s.Mappings = append(s.Mappings, SyncMapping{
Prefix: prefix,
Remote: remote,
Direction: direction,
LFS: lfs,
LFSPatterns: patterns,
Token: token,
})
}

// Remotes returns all unique remotes (default + mappings).
func (s *SyncConfig) Remotes() []string {
seen := make(map[string]bool)
Expand Down
55 changes: 54 additions & 1 deletion internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ func TestParseCloudRefresh(t *testing.T) {
// Floor: anything < 30s clamps to the default to protect a
// busy wiki from CPU churn.
{"1s", 5 * time.Minute},
{"", 5 * time.Minute}, // empty → default
{"", 5 * time.Minute}, // empty → default
{"junk", 5 * time.Minute}, // invalid → default
}
for _, tc := range tests {
Expand Down Expand Up @@ -250,3 +250,56 @@ func TestDigestConfig_BackwardsCompatible(t *testing.T) {
t.Errorf("expected default 5m on legacy config, got %v", loaded.Digest.ParseCloudRefresh())
}
}

func TestTokenForRemote(t *testing.T) {
s := &SyncConfig{
Default: "https://example.com/default.git",
Token: "default-tok",
Mappings: []SyncMapping{
{Prefix: "a", Remote: "https://example.com/a.git", Token: "a-tok"},
{Prefix: "b", Remote: "https://example.com/b.git"}, // no token → default
},
}
cases := []struct {
remote string
want string
}{
{"https://example.com/a.git", "a-tok"}, // per-mapping override wins
{"https://example.com/b.git", "default-tok"}, // falls back to default
{"https://example.com/default.git", "default-tok"},
{"https://example.com/unknown.git", "default-tok"}, // unknown → default
{"", ""}, // empty remote → no token
}
for _, c := range cases {
if got := s.TokenForRemote(c.remote); got != c.want {
t.Errorf("TokenForRemote(%q) = %q, want %q", c.remote, got, c.want)
}
}

// With no default token, an unmapped remote resolves to "".
s.Token = ""
if got := s.TokenForRemote("https://example.com/b.git"); got != "" {
t.Errorf("expected empty token with no default, got %q", got)
}
}

func TestAddMappingFullPreservesTokenWhenEmpty(t *testing.T) {
s := &SyncConfig{}
s.AddMappingFull("p", "https://example.com/p.git", SyncBidirectional, false, nil, "secret")
if s.Mappings[0].Token != "secret" {
t.Fatalf("token not set on insert: %q", s.Mappings[0].Token)
}
// Re-register without a token: existing token must be preserved.
s.AddMappingFull("p", "https://example.com/p.git", SyncPull, false, nil, "")
if s.Mappings[0].Token != "secret" {
t.Errorf("empty token overwrote stored token: %q", s.Mappings[0].Token)
}
if s.Mappings[0].Direction != SyncPull {
t.Errorf("direction not updated: %q", s.Mappings[0].Direction)
}
// Re-register with a new token: it replaces the old one.
s.AddMappingFull("p", "https://example.com/p.git", SyncPull, false, nil, "rotated")
if s.Mappings[0].Token != "rotated" {
t.Errorf("token not rotated: %q", s.Mappings[0].Token)
}
}
47 changes: 47 additions & 0 deletions internal/httpapi/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -427,12 +427,27 @@ func (s *Server) allLinks(rw http.ResponseWriter, r *http.Request) {
writeJSON(rw, links)
}

// redactedToken is the placeholder the settings API returns in place of
// a stored sync token. A GET never exposes the real token; a PUT that
// sends this sentinel back means "keep the existing token unchanged".
const redactedToken = "********"

func (s *Server) getSettings(rw http.ResponseWriter, r *http.Request) {
current, err := config.Load(s.deps.CfgPath)
if err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError)
return
}
// Redact tokens so the browser never receives raw credentials.
// config.Load returns a fresh struct, so mutating it here is safe.
if current.Sync.Token != "" {
current.Sync.Token = redactedToken
}
for i := range current.Sync.Mappings {
if current.Sync.Mappings[i].Token != "" {
current.Sync.Mappings[i].Token = redactedToken
}
}
writeJSON(rw, current)
}

Expand All @@ -449,6 +464,28 @@ func (s *Server) putSettings(rw http.ResponseWriter, r *http.Request) {
return
}

// Restore any redacted tokens from the on-disk config so a settings
// save that echoes back the mask preserves the stored credential
// instead of overwriting it with the placeholder. An empty token is
// left empty (an explicit clear); a real new value is taken as-is.
if existing, err := config.Load(s.deps.CfgPath); err == nil {
if incoming.Sync.Token == redactedToken {
incoming.Sync.Token = existing.Sync.Token
}
for i := range incoming.Sync.Mappings {
if incoming.Sync.Mappings[i].Token != redactedToken {
continue
}
incoming.Sync.Mappings[i].Token = "" // default: no prior match
for _, em := range existing.Sync.Mappings {
if em.Prefix == incoming.Sync.Mappings[i].Prefix {
incoming.Sync.Mappings[i].Token = em.Token
break
}
}
}
}

if incoming.Sync.Enabled && incoming.Sync.Default == "" && len(incoming.Sync.Mappings) == 0 {
http.Error(rw, "sync requires at least a default remote or one mapping", http.StatusBadRequest)
return
Expand All @@ -468,6 +505,16 @@ func (s *Server) putSettings(rw http.ResponseWriter, r *http.Request) {
s.applyConfig(&incoming)

slog.Info("settings saved", slog.String("path", s.deps.CfgPath))

// Redact tokens in the echoed response, mirroring getSettings.
if incoming.Sync.Token != "" {
incoming.Sync.Token = redactedToken
}
for i := range incoming.Sync.Mappings {
if incoming.Sync.Mappings[i].Token != "" {
incoming.Sync.Mappings[i].Token = redactedToken
}
}
writeJSON(rw, &incoming)
}

Expand Down
Loading
Loading