diff --git a/docs/specs/doc-viewer.md b/docs/specs/doc-viewer.md index 030dece..94128e7 100644 --- a/docs/specs/doc-viewer.md +++ b/docs/specs/doc-viewer.md @@ -1,7 +1,7 @@ --- title: Documentation Viewer -updated: 2026-06-12 -status: planned +updated: 2026-06-19 +status: implemented --- Tempest should expose the project reference documentation as a public, browsable @@ -175,25 +175,24 @@ stylesheet, for example `assets/css/components/doc-viewer.css`, and import it fr ## Phoenix implementation notes -Recommended modules: +Implemented modules: - `Tempest.Docs` context for manifest lookup, file loading, frontmatter parsing, Markdown rendering, and link rewriting. -- `TempestWeb.DocController` for `index` and `show` actions. -- `TempestWeb.DocHTML` with `index.html.heex` or `show.html.heex`. +- `TempestWeb.DocLive` for the public `/docs` and `/docs/:slug` viewer. -Recommended route placement: +Route placement: ```elixir scope "/", TempestWeb do pipe_through :browser - get "/docs", DocController, :index - get "/docs/:slug", DocController, :show + live "/docs", DocLive, :show + live "/docs/:slug", DocLive, :show end ``` -The controller should pass assigns such as: +The LiveView assigns: - `:documents` - `:document` @@ -206,15 +205,11 @@ clear boundary function so reviewers can see where HTML safety is decided. ## Caching -Docs are static project files. Initial implementation can read at request time in -`dev` and cache in memory in `prod`. A later version can add ETags or a manifest -checksum. - -A simple first-pass policy: +Docs are static project files. The current policy is: - `dev`: read every request for fast documentation iteration - `test`: read every request -- `prod`: cache rendered docs in `:persistent_term` or a supervised GenServer +- `prod`: cache successfully rendered manifest documents in `:persistent_term` Do not cache route params that are not in the manifest. diff --git a/docs/tasks/17-doc-viewer.md b/docs/tasks/17-doc-viewer.md index 1cfae0d..ad5fdfc 100644 --- a/docs/tasks/17-doc-viewer.md +++ b/docs/tasks/17-doc-viewer.md @@ -33,12 +33,12 @@ Netscape Navigator-inspired shell and a web 1.0 design (with a sidebar & search) - [x] T17-12: Add previous/next document links based on manifest order. - [x] T17-13: Link the docs viewer from the home page and any relevant public navigation. -- [ ] T17-14: Add ConnCase tests for `/docs`, `/docs/architecture`, unknown slugs, +- [x] T17-14: Add ConnCase tests for `/docs`, `/docs/architecture`, unknown slugs, sidebar navigation, relative-link rewriting, and path traversal rejection. -- [ ] T17-15: Add regression tests proving files outside `docs/reference/` cannot +- [x] T17-15: Add regression tests proving files outside `docs/reference/` cannot be rendered. -- [ ] T17-16: Add Hurl smoke test `test/smoke/doc-viewer.hurl`. -- [ ] T17-17: Add production caching +- [x] T17-16: Add Hurl smoke test `test/smoke/doc-viewer.hurl`. +- [x] T17-17: Add production caching ## Integration Tests diff --git a/lib/tempest/docs.ex b/lib/tempest/docs.ex index f462967..e19b77a 100644 --- a/lib/tempest/docs.ex +++ b/lib/tempest/docs.ex @@ -94,28 +94,19 @@ defmodule Tempest.Docs do @doc "Fetches and renders a known reference document by slug." @spec fetch_document(String.t()) :: {:ok, document()} | {:error, :not_found} def fetch_document(slug) when is_binary(slug) do - with {:ok, entry} <- lookup_manifest(slug), - {:ok, markdown} <- read_manifest_file(entry) do - {frontmatter, body} = split_frontmatter(markdown) - title = Map.get(frontmatter, "title") || entry.title - updated = Map.get(frontmatter, "updated") - rewritten_body = rewrite_reference_links(body, entry) - html = MDEx.to_html!(rewritten_body, @markdown_options) - - {:ok, - %__MODULE__{ - slug: entry.slug, - path: entry.path, - title: title, - updated: updated, - markdown: rewritten_body, - html: html - }} + with {:ok, entry} <- lookup_manifest(slug) do + cached_document({:reference_document, entry.slug}, fn -> render_reference_document(entry) end) else _ -> {:error, :not_found} end end + @doc "Returns true when a slug is present in the fixed reference manifest." + @spec known_document_slug?(String.t()) :: boolean() + def known_document_slug?(slug) when is_binary(slug) do + match?({:ok, _entry}, lookup_manifest(slug)) + end + @doc "Fetches and renders a known desktop document by fixed manifest slug." @spec fetch_desktop_document(String.t()) :: {:ok, document()} | {:error, :not_found} def fetch_desktop_document(slug) when is_binary(slug) do @@ -245,6 +236,53 @@ defmodule Tempest.Docs do end end + defp render_reference_document(entry) do + with {:ok, markdown} <- read_manifest_file(entry) do + {frontmatter, body} = split_frontmatter(markdown) + title = Map.get(frontmatter, "title") || entry.title + updated = Map.get(frontmatter, "updated") + rewritten_body = rewrite_reference_links(body, entry) + html = MDEx.to_html!(rewritten_body, @markdown_options) + + {:ok, + %__MODULE__{ + slug: entry.slug, + path: entry.path, + title: title, + updated: updated, + markdown: rewritten_body, + html: html + }} + end + end + + defp cached_document(cache_key, render_fun) do + if cache_rendered_docs?() do + persistent_key = {__MODULE__, cache_key} + + case :persistent_term.get(persistent_key, :missing) do + :missing -> + case render_fun.() do + {:ok, document} -> + :persistent_term.put(persistent_key, document) + {:ok, document} + + error -> + error + end + + document -> + {:ok, document} + end + else + render_fun.() + end + end + + defp cache_rendered_docs? do + Application.get_env(:tempest, :env, :prod) == :prod + end + defp reference_root do Path.expand("docs/reference", File.cwd!()) end diff --git a/lib/tempest_web/router.ex b/lib/tempest_web/router.ex index fef2626..34f7043 100644 --- a/lib/tempest_web/router.ex +++ b/lib/tempest_web/router.ex @@ -5,6 +5,7 @@ defmodule TempestWeb.Router do plug :accepts, ["html"] plug :fetch_session plug :fetch_live_flash + plug :reject_unknown_doc_slug plug :put_root_layout, html: {TempestWeb.Layouts, :root} plug :protect_from_forgery plug :put_secure_browser_headers @@ -131,4 +132,17 @@ defmodule TempestWeb.Router do |> Plug.Conn.put_resp_header("access-control-expose-headers", "dpop-nonce") |> Plug.Conn.put_resp_header("access-control-max-age", "100000000") end + + defp reject_unknown_doc_slug(%Plug.Conn{method: "GET", path_info: ["docs", slug]} = conn, _opts) do + if Tempest.Docs.known_document_slug?(slug) do + conn + else + conn + |> Plug.Conn.put_resp_content_type("text/html") + |> Plug.Conn.send_resp(:not_found, "Not Found") + |> Plug.Conn.halt() + end + end + + defp reject_unknown_doc_slug(conn, _opts), do: conn end diff --git a/test/smoke/doc-viewer.hurl b/test/smoke/doc-viewer.hurl new file mode 100644 index 0000000..5a62034 --- /dev/null +++ b/test/smoke/doc-viewer.hurl @@ -0,0 +1,34 @@ +GET {{base_url}}/docs +HTTP 200 +[Asserts] +header "content-type" contains "text/html" +body contains "Tempest Navigator 4.0 - Reference Documentation" +body contains "id=\"doc-bookmarks\"" +body contains "id=\"doc-content\"" +body contains "href=\"/docs/architecture\"" +body contains "Best viewed in Tempest Navigator" + +GET {{base_url}}/docs/architecture +HTTP 200 +[Asserts] +header "content-type" contains "text/html" +body contains "Architecture" +body contains "Reference file: architecture.md" +body contains "Concepts" +body contains "
"
+body contains "href=\"/docs/admin-operations\""
+
+GET {{base_url}}/docs/not-a-real-doc
+HTTP 404
+
+GET {{base_url}}/docs/..%2F..%2Fconfig%2Fprod.exs
+HTTP 400
+[Asserts]
+body not contains "SECRET_KEY_BASE"
+
+GET {{base_url}}/docs/..%252F..%252Fconfig%252Fprod.exs
+HTTP 404
+[Asserts]
+body not contains "SECRET_KEY_BASE"
+body not contains "TempestWeb.Endpoint"
diff --git a/test/tempest_web/live/doc_live_route_test.exs b/test/tempest_web/live/doc_live_route_test.exs
new file mode 100644
index 0000000..0513775
--- /dev/null
+++ b/test/tempest_web/live/doc_live_route_test.exs
@@ -0,0 +1,69 @@
+defmodule TempestWeb.DocLiveRouteTest do
+  use TempestWeb.ConnCase
+
+  test "GET /docs renders the reference index without authentication", %{conn: conn} do
+    conn = get(conn, ~p"/docs")
+    html = html_response(conn, 200)
+    document = LazyHTML.from_fragment(html)
+
+    assert has_selector?(document, "#tempest-docs")
+    assert has_selector?(document, "#doc-bookmarks")
+    assert has_selector?(document, "#doc-content")
+    assert has_selector?(document, ~s(a[href="/docs/architecture"]))
+    assert html =~ "Tempest Navigator 4.0 - Reference Documentation"
+    assert html =~ "Best viewed in Tempest Navigator"
+    assert html =~ "Reference Documentation"
+  end
+
+  test "GET /docs/architecture renders the architecture reference document", %{conn: conn} do
+    conn = get(conn, ~p"/docs/architecture")
+    html = html_response(conn, 200)
+    document = LazyHTML.from_fragment(html)
+
+    assert has_selector?(document, "#doc-title")
+    assert has_selector?(document, ~s(a[href="/docs/admin-operations"]))
+    assert has_selector?(document, ~s(a[href="/docs/blobs"]))
+    assert has_selector?(document, "pre code")
+    assert has_selector?(document, "table")
+    assert html =~ ~s(id="doc-title")
+    assert html =~ ">Architecture"
+    assert html =~ "Reference file: architecture.md"
+    assert html =~ "Concepts"
+  end
+
+  test "unknown slugs return 404", %{conn: conn} do
+    conn = get(conn, ~p"/docs/not-a-real-doc")
+
+    assert html_response(conn, 404)
+  end
+
+  test "path traversal attempts return 404 and do not read local files", %{conn: conn} do
+    conn = get(conn, "/docs/..%2F..%2Fconfig%2Fprod.exs")
+    html = html_response(conn, 404)
+
+    refute html =~ "SECRET_KEY_BASE"
+    refute html =~ "TempestWeb.Endpoint"
+  end
+
+  test "file-like paths outside docs/reference cannot be rendered", %{conn: conn} do
+    conn = get(conn, "/docs/CHANGELOG.md")
+
+    assert html_response(conn, 404)
+  end
+
+  test "relative links between reference docs resolve to viewer routes", %{conn: conn} do
+    conn = get(conn, ~p"/docs")
+    html = html_response(conn, 200)
+    document = LazyHTML.from_fragment(html)
+
+    assert has_selector?(document, ~s(#doc-content a[href="/docs/architecture"]))
+    assert has_selector?(document, ~s(#doc-content a[href="/docs/deployment-observability"]))
+    assert has_selector?(document, ~s(#doc-content a[href="/docs/identity-troubleshooting"]))
+  end
+
+  defp has_selector?(document, selector) do
+    document
+    |> LazyHTML.query(selector)
+    |> Enum.any?()
+  end
+end