diff --git a/assets/index.html b/assets/index.html index 462c3a0..c3ad4b7 100644 --- a/assets/index.html +++ b/assets/index.html @@ -1,5 +1,5 @@ - + @@ -884,6 +884,28 @@

Markdown Preview

let sseConnected = false; const loadedPacks = new Set(); + // Live-reload auth token. Either substituted into the document by + // markdown-preview.nvim at file-write time, or passed via ?t= + // in the URL on first load (then stashed in sessionStorage so it + // survives refreshes). Empty string when the server has no auth. + const LIVE_TOKEN = (() => { + const fromAttr = document.documentElement.dataset.liveToken || ''; + if (fromAttr && fromAttr !== '__LIVE_TOKEN__') { + try { sessionStorage.setItem('mdp-token', fromAttr); } catch (_) {} + return fromAttr; + } + const fromUrl = new URLSearchParams(location.search).get('t'); + if (fromUrl) { + try { sessionStorage.setItem('mdp-token', fromUrl); } catch (_) {} + return fromUrl; + } + try { return sessionStorage.getItem('mdp-token') || ''; } catch (_) { return ''; } + })(); + const withToken = (url) => { + if (!LIVE_TOKEN) return url; + return url + (url.indexOf('?') >= 0 ? '&' : '?') + 't=' + encodeURIComponent(LIVE_TOKEN); + }; + // Scroll sync state let scrollSyncPaused = false; let scrollSyncPauseTimer = null; @@ -1131,7 +1153,7 @@

Markdown Preview

// ── Content sync ────────────────────────────────────────────── async function fetchContent() { try { - const resp = await fetch('content.md?ts=' + Date.now(), { cache: 'no-store' }); + const resp = await fetch(withToken('content.md?ts=' + Date.now()), { cache: 'no-store' }); if (!resp.ok) { console.warn('[markdown-preview] content.md fetch failed:', resp.status); return null; @@ -1258,7 +1280,7 @@

Markdown Preview

// ── SSE (Server-Sent Events from live-server.nvim) ──────────── function connectSSE() { - const evtSource = new EventSource('/__live/events'); + const evtSource = new EventSource(withToken('/__live/events')); evtSource.addEventListener('reload', async () => { await sync(false); }); diff --git a/lua/markdown_preview/init.lua b/lua/markdown_preview/init.lua index f17e5f2..c149748 100644 --- a/lua/markdown_preview/init.lua +++ b/lua/markdown_preview/init.lua @@ -2,6 +2,7 @@ local ts = require("markdown_preview.ts") local util = require("markdown_preview.util") local ls_server = require("live_server.server") +local ls_util = require("live_server.util") local M = {} @@ -63,6 +64,7 @@ M._mmdr_available = nil -- nil = unchecked, true/false after probe M._last_scroll_line = nil M._is_primary = nil -- true/false/nil (takeover mode) M._takeover_port = nil -- port of primary server (secondary uses for HTTP events) +M._token = nil -- live-server auth token (primary owns; secondaries read from lockfile) local function effective_port() if M.config.port ~= 0 then return M.config.port end @@ -98,8 +100,11 @@ local function write_index(dir) error("Could not locate assets/index.html in runtimepath. Make sure the plugin ships it.") end local content = util.read_text(src) - content = content:gsub("__BOTTOM_PADDING__", tostring(M.config.bottom_padding)) - content = content:gsub("__MERMAID_ELK__", M.config.mermaid_elk and "true" or "false") + -- gsub with function replacement: avoids the "%n is a capture reference" + -- escape problem if any substituted value contains '%'. + content = content:gsub("__BOTTOM_PADDING__", function() return tostring(M.config.bottom_padding) end) + content = content:gsub("__MERMAID_ELK__", function() return M.config.mermaid_elk and "true" or "false" end) + content = content:gsub("__LIVE_TOKEN__", function() return M._token or "" end) util.write_text(dst, content) return dst end @@ -364,7 +369,7 @@ local function send_scroll_sync(bufnr) if M._server_instance then pcall(ls_server.send_event, M._server_instance, "scroll", payload) elseif M._takeover_port then - require("markdown_preview.remote").send_event(M._takeover_port, "scroll", payload) + require("markdown_preview.remote").send_event(M._takeover_port, "scroll", payload, M._token) end end @@ -405,6 +410,17 @@ end -- Public API --------------------------------------------------------------------------- +-- Build the URL the browser opens to. Embeds the auth token when one exists +-- so the first request includes it (the page then stashes it in +-- sessionStorage for refreshes). +local function browser_url(port) + local base = ("http://127.0.0.1:%d/"):format(port) + if M._token and M._token ~= "" then + return base .. "?t=" .. M._token + end + return base +end + function M.start() local bufnr = vim.api.nvim_get_current_buf() M._active_bufnr = bufnr @@ -425,26 +441,43 @@ function M.start() util.mkdirp(dir) M._workspace_dir = dir - write_index_if_needed(dir) - write_content(dir, text) - M._last_text_by_buf[bufnr] = text - - set_autocmds_for_buffer(bufnr) - - -- In takeover mode, check if another instance already owns the server + -- Decide role + token BEFORE writing index.html. The index bakes the + -- token in via the __LIVE_TOKEN__ placeholder, so we need it ready. if M.config.instance_mode == "takeover" and not M._server_instance then local lock = require("markdown_preview.lock") local lock_data = lock.read() if lock_data and lock.is_server_alive(lock_data.port) then - -- Secondary mode: server already running in another Neovim instance + -- Secondary: server is already running in another Neovim + -- instance. Adopt its token so our scroll-sync RPC works. M._is_primary = false M._takeover_port = lock_data.port + M._token = lock_data.token + write_content(dir, text) + M._last_text_by_buf[bufnr] = text + set_autocmds_for_buffer(bufnr) return end - -- Stale lock or no lock — we become primary + -- Stale lock or no lock, we become primary lock.remove() end + -- Primary path (takeover) or single-instance (multi). Generate a token + -- once per server lifetime and reuse it across retargets. + if not M._token or M._token == "" then + M._token = ls_util.random_token(16) + end + + write_index_if_needed(dir) + write_content(dir, text) + M._last_text_by_buf[bufnr] = text + + set_autocmds_for_buffer(bufnr) + + -- Pattern matching the workspace-served content file. Used by live-server + -- to require ?t= on requests for it. Escape any '.' in the + -- configured content_name so it's a literal match. + local content_path_pattern = "^/" .. M.config.content_name:gsub("%.", "%%.") .. "$" + -- Start live-server if not already running if not M._server_instance then local port = effective_port() @@ -461,6 +494,8 @@ function M.start() debounce = 100, }, features = { dirlist = { enabled = false } }, + token = M._token, + protected_paths = { content_path_pattern }, }) if not ok then vim.notify( @@ -475,16 +510,16 @@ function M.start() -- Write lock file in takeover mode if M.config.instance_mode == "takeover" then - require("markdown_preview.lock").write(inst.port, dir) + require("markdown_preview.lock").write(inst.port, dir, M._token) end if M.config.open_browser then vim.defer_fn(function() - util.open_in_browser(("http://127.0.0.1:%d/"):format(inst.port), M.config.browser) + util.open_in_browser(browser_url(inst.port), M.config.browser) end, 200) end else - -- Server already running — retarget to this buffer's workspace + -- Server already running, retarget to this buffer's workspace local index_path = vim.fs.joinpath(dir, M.config.index_name) pcall(ls_server.update_target, M._server_instance, dir, index_path) pcall(ls_server.reload, M._server_instance, M.config.content_name) @@ -492,7 +527,7 @@ function M.start() -- No browser tab connected (user closed it)? Re-open. if M.config.open_browser and ls_server.connected_client_count(M._server_instance) == 0 then vim.defer_fn(function() - util.open_in_browser(("http://127.0.0.1:%d/"):format(M._server_instance.port), M.config.browser) + util.open_in_browser(browser_url(M._server_instance.port), M.config.browser) end, 200) end end @@ -522,6 +557,7 @@ function M.stop() M._last_scroll_line = nil M._is_primary = nil M._takeover_port = nil + M._token = nil end return M diff --git a/lua/markdown_preview/lock.lua b/lua/markdown_preview/lock.lua index 29dca60..62e1b77 100644 --- a/lua/markdown_preview/lock.lua +++ b/lua/markdown_preview/lock.lua @@ -21,14 +21,22 @@ function M.read() return tbl end -function M.write(port, workspace) +function M.write(port, workspace, token) local path = lock_path() local dir = path:match("^(.+)/[^/]+$") if dir and vim.fn.isdirectory(dir) == 0 then vim.fn.mkdir(dir, "p") end - local json = vim.json.encode({ port = port, workspace = workspace, pid = vim.fn.getpid() }) - local fd = assert(uv.fs_open(path, "w", 420)) + local json = vim.json.encode({ + port = port, + workspace = workspace, + pid = vim.fn.getpid(), + token = token, -- nil OK; secondary instances need this to hit /__live/inject + }) + -- Mode 0600 (decimal 384) so the token isn't world-readable on multi-user + -- systems. Use fs_open + immediate truncate so older lockfiles with looser + -- modes get replaced cleanly. + local fd = assert(uv.fs_open(path, "w", 384)) assert(uv.fs_write(fd, json, 0)) assert(uv.fs_close(fd)) end diff --git a/lua/markdown_preview/remote.lua b/lua/markdown_preview/remote.lua index d526a09..34b8de4 100644 --- a/lua/markdown_preview/remote.lua +++ b/lua/markdown_preview/remote.lua @@ -3,12 +3,16 @@ local uv = vim.loop local M = {} -function M.send_event(port, event_type, json_data) +function M.send_event(port, event_type, json_data, token) local tcp = uv.new_tcp() local encoded = vim.uri_encode(json_data) + local query = string.format("event=%s&data=%s", event_type, encoded) + if token and token ~= "" then + query = query .. "&t=" .. vim.uri_encode(token) + end local req = string.format( - "GET /__live/inject?event=%s&data=%s HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n", - event_type, encoded + "GET /__live/inject?%s HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n", + query ) tcp:connect("127.0.0.1", port, function(err) if err then pcall(function() tcp:close() end); return end diff --git a/tests/token_auth_test.lua b/tests/token_auth_test.lua new file mode 100644 index 0000000..8e887f7 --- /dev/null +++ b/tests/token_auth_test.lua @@ -0,0 +1,89 @@ +-- tests/token_auth_test.lua +-- End-to-end check that :MarkdownPreview generates a token, threads it into +-- the served HTML, gates content.md, and that scroll-sync RPC carries it. +-- +-- Run: nvim --headless -c "set rtp+=./live-server-rtp" -c "set rtp+=." \ +-- -c "luafile tests/token_auth_test.lua" -c "qa!" +-- +-- The CI workflow shims live-server.nvim into ./live-server-rtp before run. + +local uv = vim.loop + +-- ─── Setup: open a markdown buffer ────────────────────────────────────────── +local tmpdir = vim.fn.tempname() +vim.fn.mkdir(tmpdir, "p") +local mdfile = vim.fs.joinpath(tmpdir, "test.md") +do + local fd = uv.fs_open(mdfile, "w", 420) + uv.fs_write(fd, "# hello\n\nbody text here.\n", 0) + uv.fs_close(fd) +end + +vim.cmd("edit " .. mdfile) +vim.bo.filetype = "markdown" + +-- ─── Configure: avoid opening a real browser, force multi mode for isolation +local mp = require("markdown_preview") +mp.setup({ + open_browser = false, + instance_mode = "multi", +}) + +-- ─── Start ────────────────────────────────────────────────────────────────── +mp.start() + +local passed, failed = 0, 0 +local function ok(cond, msg) + if cond then + passed = passed + 1 + print(" PASS: " .. msg) + else + failed = failed + 1 + print(" FAIL: " .. msg) + end +end + +-- Server instance + token must exist +ok(mp._server_instance ~= nil, "server instance created") +ok(type(mp._token) == "string" and #mp._token == 32, "_token is 32 hex chars") +ok(mp._token:match("^[0-9a-f]+$") ~= nil, "_token is pure hex") + +local port = mp._server_instance.port +ok(type(port) == "number" and port > 0, "server bound to a port") + +-- ─── HTTP curl helper ─────────────────────────────────────────────────────── +local function http_get(url) + local out = vim.fn.system({ "curl", "-s", "-o", "-", "-w", "\nHTTPSTATUS:%{http_code}", url }) + local body, status = out:match("^(.*)\nHTTPSTATUS:(%d+)%s*$") + return { status = tonumber(status), body = body or "" } +end + +-- Static index reachable without token +local r = http_get(("http://127.0.0.1:%d/"):format(port)) +ok(r.status == 200, "/ (index) is 200 without token") +ok(r.body:find("data%-live%-token=\"" .. mp._token .. "\"") ~= nil, + "index.html has data-live-token attribute set to current token") + +-- content.md is gated +r = http_get(("http://127.0.0.1:%d/content.md"):format(port)) +ok(r.status == 401, "/content.md without token is 401") + +r = http_get(("http://127.0.0.1:%d/content.md?t=%s"):format(port, mp._token)) +ok(r.status == 200, "/content.md with correct token is 200") +ok(r.body:find("hello") ~= nil, "/content.md body contains buffer text") + +-- ─── Stop and verify cleanup ──────────────────────────────────────────────── +mp.stop() +ok(mp._token == nil, "_token cleared after stop") +ok(mp._server_instance == nil, "_server_instance cleared after stop") + +-- Port no longer accepts connections (give it a moment) +vim.wait(200, function() return false end) +r = http_get(("http://127.0.0.1:%d/"):format(port)) +ok(r.status == nil or r.status == 0, "port no longer responds after stop (status=" .. tostring(r.status) .. ")") + +print(string.format("\n========================================")) +print(string.format("Results: %d passed, %d failed", passed, failed)) +print(string.format("========================================")) + +if failed > 0 then vim.cmd("cq 1") end