diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 705be8c..4cc7c6f 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -14,8 +14,26 @@ }, "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 + // free TCP port, writes it to .devcontainer/ports.env. The container + // runs with --network host (see runArgs) so the same port number is + // both the in-container bind and the host-visible address — no port + // forwarding involved. See the wiki page + // [[preferences/devcontainer-ports]] for the full rationale and the + // consumer recipes (launch.json, tasks.json, host scripts). + "initializeCommand": ".devcontainer/initializeCommand.sh", "postCreateCommand": "go version && node --version", "postAttachCommand": "npm install --prefix webui", "customizations": { @@ -26,7 +44,10 @@ "ethan-reesor.vscode-go-test-adapter", "idered.npm", "qwtel.sqlite-viewer", - "dbaeumer.vscode-eslint" + "dbaeumer.vscode-eslint", + // shellCommand.execute input type, used by launch.json to read + // .devcontainer/ports.env into the Chrome launch URL. + "augustocdias.tasks-shell-input" ], "settings": { "go.buildTags": "", @@ -36,7 +57,19 @@ } } }, - "appPort": ["127.0.0.1:51888:4242"], + // No appPort: --network host below means no port forwarding is + // involved, so there's no host:container mapping to specify. The + // server binds directly on the host's network namespace at whatever + // port initializeCommand picked. + // + // --env-file injects MIND_MAP_HOST_PORT into the container so the + // mind-map binary (which reads it via `serve --addr`) listens on + // the right port. host-side consumers (launch.json, scripts) read + // the same file directly. + "runArgs": [ + "--network", "host", + "--env-file", ".devcontainer/ports.env" + ], "portsAttributes": { "4242": { "label": "mind-map Server (devcontainer)", diff --git a/.devcontainer/initializeCommand.sh b/.devcontainer/initializeCommand.sh new file mode 100755 index 0000000..ecee54f --- /dev/null +++ b/.devcontainer/initializeCommand.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Runs on the HOST (not in the container) before docker run. Picks a +# free TCP port for the mind-map server and writes it to +# .devcontainer/ports.env. The value is consumed in two places: +# +# 1. Inside the container, via runArgs --env-file, so the mind-map +# binary's `serve --addr` reads $MIND_MAP_HOST_PORT. +# 2. On the host, by tools (launch.json, the screenshot harness, +# ad-hoc curl) that need to know what URL to hit. +# +# Because the container runs with --network host (see devcontainer.json +# runArgs), there is no port forwarding involved — the container binds +# directly to the host's network namespace, so the same port number is +# the host port. That's why we don't need ${localEnv:...} substitution +# in appPort (which doesn't work for values produced by +# initializeCommand anyway, since appPort substitution happens before +# initializeCommand runs). +# +# See: [[preferences/devcontainer-ports]] in the mind-map wiki for the +# full pattern and the rationale. +set -euo pipefail + +# Preferred starting points. The value is stable across normal runs +# when the preferred slot is free, so browser history / bookmarks / +# muscle memory keep working. Only drifts when there's a real +# collision (another worktree's devcontainer, a stray host process, +# a VS Code port-forwarding daemon squatting on it). +PREFERRED=(51888 51889 51890 51891 51892 51893) + +pick_port() { + local p + for p in "${PREFERRED[@]}"; do + if ! ss -tln "sport = :$p" 2>/dev/null | grep -q LISTEN; then + echo "$p" + return + fi + done + # Kernel-assigned fallback. Bind a socket to port 0, read what we + # got, close it. Tiny TOCTOU window before the container claims it; + # in practice we don't hit it. + python3 -c 'import socket; s = socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()' +} + +PORT=$(pick_port) + +# `cd` so the relative path is stable regardless of where the +# devcontainer CLI was invoked from. +cd "$(dirname "$0")" + +# `--env-file` (used in runArgs) accepts bare KEY=VALUE lines. Don't +# quote the value — docker chokes on that. +cat > ports.env <} +// for browser-friendly uploads. +// +// - GET /assets/.assets/ — serve the bytes of an +// uploaded asset. Lives outside the /api/ prefix so the web UI can +// reference it directly from tags rendered by Goldmark. +// For SVG specifically, a strict Content-Security-Policy is set so +// embedded scripts and external loads cannot execute. + +package httpapi + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/aniongithub/mind-map/internal/wiki" +) + +// registerAssets wires the asset routes. Called from register(). +func (s *Server) registerAssets(mux *http.ServeMux) { + mux.HandleFunc("POST /api/assets", s.uploadAsset) + mux.HandleFunc("DELETE /api/assets/{path...}", s.deleteAsset) + mux.HandleFunc("GET /assets/{path...}", s.serveAsset) +} + +// uploadAssetJSON is the JSON-body shape for asset uploads. Mirrors +// the MCP tool's input so a client picking either transport sees the +// same field names. +type uploadAssetJSON struct { + Page string `json:"page"` + Name string `json:"name"` + ContentBase64 string `json:"content_base64"` +} + +// uploadAssetResponse is the success payload for POST /api/assets. +type uploadAssetResponse struct { + Path string `json:"path"` + URL string `json:"url"` + SizeBytes int64 `json:"size_bytes"` + MIME string `json:"mime"` +} + +// uploadAsset handles POST /api/assets. Accepts JSON or multipart bodies. +// The page and name fields are required; content arrives as either +// base64-encoded JSON or a multipart "file" part. +func (s *Server) uploadAsset(rw http.ResponseWriter, r *http.Request) { + page, name, content, err := readAssetUpload(r) + if err != nil { + http.Error(rw, err.Error(), http.StatusBadRequest) + return + } + + uploaded, err := s.deps.Wiki.UploadAsset(r.Context(), page, name, content) + if err != nil { + slog.Warn("http upload_asset failed", + slog.String("page", page), + slog.String("name", name), + slog.Int("bytes", len(content)), + slog.Any("error", err), + ) + switch { + case errors.Is(err, wiki.ErrAssetTooLarge): + http.Error(rw, err.Error(), http.StatusRequestEntityTooLarge) + case errors.Is(err, wiki.ErrUnsupportedAssetType): + http.Error(rw, err.Error(), http.StatusUnsupportedMediaType) + default: + http.Error(rw, err.Error(), http.StatusBadRequest) + } + return + } + + info, statErr := s.deps.Wiki.StatAsset(r.Context(), uploaded) + if statErr != nil { + slog.Warn("http upload_asset stat failed", + slog.String("path", uploaded), slog.Any("error", statErr)) + info = &wiki.AssetInfo{Path: uploaded} + } + + rw.WriteHeader(http.StatusCreated) + writeJSON(rw, uploadAssetResponse{ + Path: uploaded, + URL: "/assets/" + uploaded, + SizeBytes: info.SizeBytes, + MIME: info.MIME, + }) +} + +// readAssetUpload extracts (page, name, content) from either a JSON +// body or a multipart/form-data body. Returns descriptive errors that +// the caller can pass straight to http.Error. +// +// Body size is bounded by http.MaxBytesReader using the wiki's +// MaxAssetBytes (or default) plus a small overhead for the multipart +// framing. Going over the cap is reported as "request entity too +// large" by the standard library. +func readAssetUpload(r *http.Request) (page, name string, content []byte, err error) { + maxBytes := defaultUploadCapForRequest(r) + r.Body = http.MaxBytesReader(nil, r.Body, maxBytes) + + ct := r.Header.Get("Content-Type") + switch { + case strings.HasPrefix(ct, "multipart/form-data"): + // 32 MiB in-memory threshold matches stdlib defaults for + // form parsing; anything larger gets spooled to a temp + // file by the multipart reader. + if err := r.ParseMultipartForm(32 << 20); err != nil { + return "", "", nil, errors.New("parse multipart: " + err.Error()) + } + page = r.FormValue("page") + name = r.FormValue("name") + + f, hdr, ferr := r.FormFile("file") + if ferr != nil { + return "", "", nil, errors.New("missing 'file' part: " + ferr.Error()) + } + defer f.Close() + if name == "" { + name = hdr.Filename + } + data, rerr := io.ReadAll(f) + if rerr != nil { + return "", "", nil, errors.New("read 'file' part: " + rerr.Error()) + } + content = data + + default: + // Treat anything else as JSON for simplicity. application/ + // json is the documented happy path; other content types + // either decode as JSON or get a clear parse error. + var body uploadAssetJSON + if derr := json.NewDecoder(r.Body).Decode(&body); derr != nil { + return "", "", nil, errors.New("invalid JSON: " + derr.Error()) + } + page = body.Page + name = body.Name + if body.ContentBase64 == "" { + return "", "", nil, errors.New("content_base64 is required for JSON uploads") + } + data, derr := base64.StdEncoding.DecodeString(body.ContentBase64) + if derr != nil { + if alt, altErr := base64.URLEncoding.DecodeString(body.ContentBase64); altErr == nil { + data = alt + } else { + return "", "", nil, errors.New("decode content_base64: " + derr.Error()) + } + } + content = data + } + + if page == "" { + return "", "", nil, errors.New("page is required") + } + if name == "" { + return "", "", nil, errors.New("name is required") + } + return page, name, content, nil +} + +// defaultUploadCapForRequest returns the HTTP-level body cap for an +// upload. We don't have a clean handle on Wiki.MaxAssetBytes from +// here without expanding the Deps surface, so we use a generous +// constant upper bound (128 MiB) and let the wiki layer report the +// precise cap to the client when it rejects via ErrAssetTooLarge. +// The cap mostly exists to bound multipart parsing memory. +func defaultUploadCapForRequest(_ *http.Request) int64 { + return 128 * 1024 * 1024 +} + +// deleteAsset handles DELETE /api/assets/. Removes the asset +// file (and any index rows referencing it). Pages that still embed +// the asset will have a dangling markdown reference until they are +// edited — the caller is expected to clean those up if it cares. +func (s *Server) deleteAsset(rw http.ResponseWriter, r *http.Request) { + assetPath := r.PathValue("path") + if assetPath == "" { + http.Error(rw, "asset path is required", http.StatusBadRequest) + return + } + + if err := s.deps.Wiki.DeleteAsset(r.Context(), assetPath); err != nil { + if errors.Is(err, wiki.ErrAssetNotFound) { + http.NotFound(rw, r) + return + } + slog.Warn("http delete_asset failed", + slog.String("path", assetPath), slog.Any("error", err)) + http.Error(rw, err.Error(), http.StatusBadRequest) + return + } + + writeJSON(rw, map[string]string{"status": "deleted", "path": assetPath}) +} + +// serveAsset handles GET /assets/. Reads the asset from the +// wiki and streams it back with the correct Content-Type. SVG is +// served with a strict CSP to neutralize script-injection from +// hand-crafted SVG payloads. +// +// The /assets prefix is deliberately distinct from the SPA static +// handler at "/" so URLs in markdown (rewritten by the web UI to +// /assets/) don't conflict with the React/Preact routes. +func (s *Server) serveAsset(rw http.ResponseWriter, r *http.Request) { + assetPath := r.PathValue("path") + if assetPath == "" { + http.NotFound(rw, r) + return + } + + data, mime, err := s.deps.Wiki.ReadAsset(r.Context(), assetPath) + if err != nil { + if errors.Is(err, wiki.ErrAssetNotFound) { + http.NotFound(rw, r) + return + } + slog.Warn("http serve_asset failed", + slog.String("path", assetPath), slog.Any("error", err)) + http.Error(rw, err.Error(), http.StatusBadRequest) + return + } + + rw.Header().Set("Content-Type", mime) + rw.Header().Set("Cache-Control", "public, max-age=300") + // Conservative CSP for SVG: no scripts, no external loads, no + // inline event handlers. Stops script-injection attacks via + // embedded