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
28 changes: 25 additions & 3 deletions assets/index.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en" data-theme="dark" data-bottom-padding="__BOTTOM_PADDING__" data-mermaid-elk="__MERMAID_ELK__">
<html lang="en" data-theme="dark" data-bottom-padding="__BOTTOM_PADDING__" data-mermaid-elk="__MERMAID_ELK__" data-live-token="__LIVE_TOKEN__">

<head>
<meta charset="utf-8" />
Expand Down Expand Up @@ -884,6 +884,28 @@ <h1>Markdown Preview</h1>
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=<token>
// 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;
Expand Down Expand Up @@ -1131,7 +1153,7 @@ <h1>Markdown Preview</h1>
// ── 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;
Expand Down Expand Up @@ -1258,7 +1280,7 @@ <h1>Markdown Preview</h1>

// ── 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);
});
Expand Down
68 changes: 52 additions & 16 deletions lua/markdown_preview/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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=<token> 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()
Expand All @@ -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(
Expand All @@ -475,24 +510,24 @@ 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)

-- 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
Expand Down Expand Up @@ -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
14 changes: 11 additions & 3 deletions lua/markdown_preview/lock.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions lua/markdown_preview/remote.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
89 changes: 89 additions & 0 deletions tests/token_auth_test.lua
Original file line number Diff line number Diff line change
@@ -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