diff --git a/.gitignore b/.gitignore index 471f8e6..9fc59ee 100644 --- a/.gitignore +++ b/.gitignore @@ -7,5 +7,11 @@ /bin/fluence /data/ -/meta/ + +# meta/ is runtime data (users, ACLs, sessions, the active theme...) and +# stays out of git, except the shipped example theme under meta/themes/. +/meta/* +!/meta/themes +/meta/themes/* +!/meta/themes/example diff --git a/config/options.cr b/config/options.cr index f30b625..b5e1081 100644 --- a/config/options.cr +++ b/config/options.cr @@ -32,6 +32,50 @@ module Fluence # with FLUENCE_PUBLICDIR. @publicdir = ::File.expand_path ENV.fetch("FLUENCE_PUBLICDIR") { default_publicdir } + # Active theme's name; its override files are read from + # metadir/themes// (see @themedir below). Defaults to + # "default"; set with FLUENCE_THEME. The "example" theme ships in + # metadir/themes/example/ as a documented starting point. + @theme = ENV.fetch("FLUENCE_THEME", "default") + + # Directory of theming overrides for the active theme: header.html, + # footer.html, and nav_extra.html (raw HTML dropped into the layout + # as-is), custom.css (appended after the built-in stylesheet), and + # custom.js (appended after the built-in scripts, at the end of the + # body). Any file that's missing is simply skipped, so a theme can + # override as much or as little as it wants without touching the + # templates. + @themedir = ::File.join(@metadir, "themes", @theme) + + # Text that replaces a link to a page or attachment the reader can't + # read, wherever page content links to one (e.g. a page under an + # ACL-hidden directory linked from a readable page): the link and + # its text are both dropped, so the target's location or title isn't + # disclosed either. Set with FLUENCE_INACCESSIBLE_LABEL. + @inaccessible_label = ENV.fetch("FLUENCE_INACCESSIBLE_LABEL", "Censored") + + # Text that replaces an `![alt](src)` image embedded directly in + # page content (not wrapped in a link) whose attachment the reader + # can't read; unlike a hidden link, there's no separate link text to + # drop, just the `src` that would otherwise disclose the attachment's + # path. Set with FLUENCE_IMAGE_INACCESSIBLE_LABEL. + @image_inaccessible_label = ENV.fetch("FLUENCE_IMAGE_INACCESSIBLE_LABEL", "[Image Not Available]") + + # Extra language codes pages can be translated into, e.g. "de,fr". + # A page's default-language content lives at "pages/.md" as + # usual; a translation lives alongside it at "pages/..md" + # and is reached as "?lang=" on the page's normal URL, so ACLs + # (which key off the URL) cover every translation automatically. + # Empty (the default) turns the feature off. Set with FLUENCE_LANGUAGES. + @languages = ENV.fetch("FLUENCE_LANGUAGES", "").split(",").map(&.strip).reject(&.empty?) + + # Label for the untranslated "pages/.md" content in the + # language switcher; purely cosmetic, since that content is always + # what's served when no ?lang= (or an unconfigured one) is given. + # Set with FLUENCE_DEFAULT_LANG. + @default_lang = ENV.fetch("FLUENCE_DEFAULT_LANG", "en") + @languages = @languages.reject { |code| code == @default_lang } + # Visible part of URL through which pages are accessed, e.g. /pages/my_page @pages_prefix = "/pages" # Start page - homepage. Defaults to /pages/home @@ -78,6 +122,7 @@ module Fluence Dir.mkdir_p @datadir Dir.mkdir_p @metadir + Dir.mkdir_p @themedir end getter host : String @@ -89,6 +134,12 @@ module Fluence getter datadir : String getter metadir : String getter publicdir : String + getter theme : String + getter themedir : String + getter inaccessible_label : String + getter image_inaccessible_label : String + getter languages : Array(String) + getter default_lang : String getter homepage : String getter pages_prefix : String getter media_prefix : String diff --git a/spec/controllers_spec.cr b/spec/controllers_spec.cr index 059d756..78b3584 100644 --- a/spec/controllers_spec.cr +++ b/spec/controllers_spec.cr @@ -536,7 +536,11 @@ describe "page menu over HTTP" do Fluence::Page.new(name).update! SPEC_USER, "# #{name.split('/').last.capitalize}\n" end - body = SpecClient.new.get("/pages/docs/guide").body + # The page menu is part of the signed-in layout; guests get a + # stripped-down view without it. + client = SpecClient.login "editor", "sekrit123" + + body = client.get("/pages/docs/guide").body body.should contain %(Guide) body.should contain %(
) body.should contain %(
) @@ -546,13 +550,34 @@ describe "page menu over HTTP" do body.should contain %(href="/pages/docs" title="Up: Docs">) body.should contain %(href="/pages/docs/guide/install" title="Next: Install">) - body = SpecClient.new.get("/pages/alpha").body + body = client.get("/pages/alpha").body body.should contain %(‹ Prev) body.should contain %(href="/pages/docs" title="Next: Docs">) SpecClient.new.get("/sitemap").body.should contain %(
) end end + + it "omits unreadable pages, and directories left with nothing readable inside, from the tree" do + with_each_storage do |_, _| + Fluence::Page.new("hidden/secret").update! SPEC_USER, "# Secret\n" + Fluence::Page.new("visible").update! SPEC_USER, "# Visible\n" + + Fluence::ACL["guest"]["#{Fluence::OPTIONS.pages_prefix}/hidden/*"] = Acl::Perm::None + begin + body = SpecClient.new.get("/sitemap").body + body.should_not contain "hidden" + body.should_not contain "secret" + body.should contain %(href="/pages/visible") + + # A logged-in user without the restriction still sees it. + client = SpecClient.login "editor", "sekrit123" + client.get("/sitemap").body.should contain %(href="/pages/hidden/secret") + ensure + Fluence::ACL["guest"].delete "#{Fluence::OPTIONS.pages_prefix}/hidden/*" + end + end + end end describe "save and continue over HTTP" do @@ -666,7 +691,9 @@ describe "attachment list on a page" do Fluence::Media.new("parent/own.txt").update! SPEC_USER, "own" Fluence::Media.new("parent/child/pic.png").update! SPEC_USER, "png" - response = SpecClient.new.get "/pages/parent" + # The attachments list is part of the signed-in layout; guests get a + # stripped-down view without it. + response = SpecClient.login("editor", "sekrit123").get "/pages/parent" response.status_code.should eq 200 response.body.should contain %(href="/media/parent/own.txt") response.body.should_not contain "/media/parent/child/pic.png" @@ -733,7 +760,34 @@ describe "attachment list markup" do SpecClient.new.get("/pages/att-list").body.should_not contain %(name="delete") Fluence::Page.new("no-att").update! SPEC_USER, "# No Att\n" - SpecClient.new.get("/pages/no-att").body.should contain %(
None.
) + client.get("/pages/no-att").body.should contain %(
None.
) + end + end +end + +describe "guest page view" do + it "shows only content and navigation, hiding the menu, attachments, and page management" do + with_each_storage do |_, _| + Fluence::Page.new("guest-view").update! SPEC_USER, "# Guest View\nBody text.\n" + Fluence::Media.new("guest-view/a.txt").update! SPEC_USER, "A" + + body = SpecClient.new.get("/pages/guest-view").body + body.should contain "Body text." + body.should contain %(href="/pages/home">Home) + body.should contain %(href="/sitemap") + body.should contain %(href="/users/login") + body.should contain %(href="/users/register") + body.should_not contain "pages-hierarchy" + body.should_not contain "page-nav" + body.should_not contain "manage-attachments" + body.should_not contain "manage-page" + body.should_not contain "page-permissions" + + # Signed-in users still get the full view. + client = SpecClient.login "editor", "sekrit123" + full_body = client.get("/pages/guest-view").body + full_body.should contain "pages-hierarchy" + full_body.should contain "manage-attachments" end end end @@ -775,12 +829,16 @@ describe "table of contents" do with_each_storage do |_, _| Fluence::Page.new("toc-esc").update! SPEC_USER, "# Toc Esc\n## A bold & \"quoted\" one\n" - body = SpecClient.new.get("/pages/toc-esc").body + body = SpecClient.login("editor", "sekrit123").get("/pages/toc-esc").body entry = body.match!(/
  • (A .*?)<\/a>/) entry[1].should eq "a-b-bold-/b-quoted-one" entry[2].should eq "A <b>bold</b> & "quoted" one" - body.should contain %(

    ) body.should_not contain "bold" + + # Guests get the rendered body (with the heading) but no TOC. + guest = SpecClient.new.get("/pages/toc-esc").body + guest.should contain %(

    ) + guest.should_not contain "bold" end end end diff --git a/spec/markdown_spec.cr b/spec/markdown_spec.cr index de6d099..f96d116 100644 --- a/spec/markdown_spec.cr +++ b/spec/markdown_spec.cr @@ -28,10 +28,11 @@ describe Fluence::Markdown do Fluence::Markdown.to_html("```\n# not a heading\n```\n").should_not contain "\n\ntext bold\n") - html.should_not contain "onerror" - html.should_not contain "onclick" + html.should_not contain "/ through for icons and custom styling" do + html = Fluence::Markdown.to_html(%(An icon and text.)) + html.should contain %() + html.should contain %(text) + end + + it "lets headings, lists, and layout containers through as raw HTML" do + html = Fluence::Markdown.to_html(<<-HTML +
    +
    +

    Heading

    +
    • one
    • two
    +
    1. first
    +
    +
    + HTML + ) + html.should contain %(
    ) + html.should contain "
    " + html.should contain %(

    Heading

    ) + html.should contain "
    • one
    • two
    " + html.should contain "
    1. first
    " + html.should contain "
    " + end + + it "still escapes an unsafe tag disguised with a class attribute" do + html = Fluence::Markdown.to_html(%()) + html.should_not contain "text
    )) + html.should_not contain %(
    ) + end + + it "replaces a link to an unreadable page or attachment with the inaccessible label, dropping its text" do + with_each_storage do |_, _| + Fluence::ACL["guest"]["#{Fluence::OPTIONS.pages_prefix}/hidden/*"] = Acl::Perm::None + Fluence::ACL["guest"]["#{Fluence::OPTIONS.media_prefix}/hidden/*"] = Acl::Perm::None + begin + html = Fluence::Markdown.to_html( + "[Secret Plans](/pages/hidden/secret) and [a file](/media/hidden/secret/plans.pdf) and [Visible](/pages/visible)", + Fluence::USERS.default.not_nil! + ) + html.should_not contain "Secret Plans" + html.should_not contain "hidden/secret" + html.should contain "#{Fluence::OPTIONS.inaccessible_label}" + html.should contain %(Visible) + ensure + Fluence::ACL["guest"].delete "#{Fluence::OPTIONS.pages_prefix}/hidden/*" + Fluence::ACL["guest"].delete "#{Fluence::OPTIONS.media_prefix}/hidden/*" + end + end + end + + it "resolves a relative link the same way the page menu and intlinks do, before checking access" do + with_each_storage do |_, _| + Fluence::ACL["guest"]["#{Fluence::OPTIONS.pages_prefix}/hidden/*"] = Acl::Perm::None + begin + # Written on page "new-page" (context_name ""), "hidden/test2" is a + # sibling target and resolves to /pages/hidden/test2 — same as the + # browser resolving the href against the page's own URL. + html = Fluence::Markdown.to_html( + "[test](hidden/test2)", Fluence::USERS.default.not_nil!, "" + ) + html.should_not contain "hidden/test2" + html.should contain "#{Fluence::OPTIONS.inaccessible_label}" + ensure + Fluence::ACL["guest"].delete "#{Fluence::OPTIONS.pages_prefix}/hidden/*" + end + end + end + + it "replaces an embedded image the reader can't read with the image-inaccessible label, dropping its src" do + with_each_storage do |_, _| + Fluence::ACL["guest"]["#{Fluence::OPTIONS.media_prefix}/hidden/*"] = Acl::Perm::None + begin + html = Fluence::Markdown.to_html( + "![Secret Diagram](/media/hidden/plans.png) and ![Visible](/media/visible.png)", + Fluence::USERS.default.not_nil! + ) + html.should_not contain "Secret Diagram" + html.should_not contain "hidden/plans.png" + html.should contain "#{Fluence::OPTIONS.image_inaccessible_label}" + html.should contain %(Visible) + ensure + Fluence::ACL["guest"].delete "#{Fluence::OPTIONS.media_prefix}/hidden/*" + end + end + end end describe Fluence::WikilinkMigration do diff --git a/src/controllers/helpers/page.cr b/src/controllers/helpers/page.cr index bf7d6a5..3bbdc7d 100644 --- a/src/controllers/helpers/page.cr +++ b/src/controllers/helpers/page.cr @@ -24,8 +24,11 @@ module Fluence::Helpers::Page end end - # Builds the tree of all pages from the catalog in one pass, siblings - # ordered by name. + # Builds the tree of the pages *current_user* may read, from the + # catalog in one pass, siblings ordered by name. A directory carries no + # ACL of its own, so an unreadable page simply omits its name from the + # tree; a directory that exists only to route to unreadable pages never + # gets a node at all, rather than showing as an empty, un-openable entry. def page_tree : Array(TreeNode) catalog_tree Fluence::PAGES, Fluence::OPTIONS.pages_prefix end @@ -38,7 +41,7 @@ module Fluence::Helpers::Page private def catalog_tree(catalog, url_prefix : String) : Array(TreeNode) roots = [] of TreeNode index = {} of String => TreeNode - titles = catalog.titles + titles = catalog.titles.select { |name, _| Fluence::ACL.permitted? current_user, "#{url_prefix}/#{name}", Acl::Perm::Read } titles.keys.sort!.each do |name| siblings = roots path = "" diff --git a/src/controllers/pages_controller.cr b/src/controllers/pages_controller.cr index 361b524..d9e5f16 100644 --- a/src/controllers/pages_controller.cr +++ b/src/controllers/pages_controller.cr @@ -10,7 +10,7 @@ class PagesController < ApplicationController def show # Permissions are checked against the page's canonical URL rather than # the path as typed: /pages/Secret and /pages/secr%65t are "secret". - page = Fluence::Page.new params.url["path"] + page = Fluence::Page.new params.url["path"], page_lang acl_permit! :read, page.url flash["danger"] = params.query["flash.danger"] if params.query["flash.danger"]? page.process! if page.exists? @@ -41,7 +41,7 @@ class PagesController < ApplicationController private def show_revision(page, rev) commit = commit_or_redirect(page, rev) || return body = page.read_at commit - body_html = Fluence::Markdown.to_html body + body_html = Fluence::Markdown.to_html body, current_user, page.name writable = Fluence::ACL.permitted? current_user, page.url, Acl::Perm::Write tree = page_tree title = "#{page.title} @ #{commit.short_oid} - #{title()}" @@ -74,7 +74,7 @@ class PagesController < ApplicationController private def show_show(page, media) exists = page.exists? body = exists ? (page.read rescue "") : "" - body_html = Fluence::Markdown.to_html body + body_html = Fluence::Markdown.to_html body, current_user, page.name last_commit = exists ? page.history(1).first? : nil Fluence::ACL.load! writable = Fluence::ACL.permitted? current_user, page.url, Acl::Perm::Write @@ -96,7 +96,11 @@ class PagesController < ApplicationController tree = page_tree - render "show.slang" + if user_signed_in? + render "show.slang" + else + render "show_guest.slang" + end end # Whether the editor opens in edit mode (as opposed to preview) for @@ -118,7 +122,7 @@ class PagesController < ApplicationController # post /pages/*path def update - page = Fluence::Page.new params.url["path"] + page = Fluence::Page.new params.url["path"], page_lang acl_permit! :write, page.url page.process! if page.exists? if params.body["rename"]? @@ -183,6 +187,7 @@ class PagesController < ApplicationController pages.each do |page| begin + page.translations.each { |code| Fluence::Page.new(page.name, code).delete current_user } page.delete current_user if page.exists? flash["success success-#{page.name}"] = "Page '#{page.name}' has been deleted" rescue e @@ -278,12 +283,19 @@ class PagesController < ApplicationController return end @env.response.status_code = 404 if pages.empty? - sections = pages.map { |page| {page, Fluence::Markdown.to_html(page.read)} } + sections = pages.map { |page| {page, Fluence::Markdown.to_html(page.read, current_user, page.name)} } tree = page_tree title = "Pages titled '#{slug}' - #{title()}" render "titles.slang" end + # The translation requested via ?lang= (query, or body for POSTs), or nil + # for the default language; unconfigured codes fall back to nil too. + private def page_lang : String? + lang = params.query["lang"]? || params.body["lang"]? + lang if lang && Fluence::OPTIONS.languages.includes?(lang) + end + private def subtree_of(page) prefix = page.name + "/" Fluence::PAGES.names.select(&.starts_with?(prefix)).map { |name| Fluence::Page.new(name).process! } diff --git a/src/markdown.cr b/src/markdown.cr index 0f55057..6116a5a 100644 --- a/src/markdown.cr +++ b/src/markdown.cr @@ -6,16 +6,42 @@ require "./page" # relative target), so no preprocessing pass is needed; see # `Page::InternalLinks` for how they are recognized and tracked. # -# Rendering is `safe`: raw HTML in the content is omitted and link/image -# targets with script-capable schemes (javascript:, vbscript:, data: -# other than images) are dropped. Anyone allowed to edit a page would -# otherwise be able to run script in every reader's browser. +# Rendering is `safe`: link/image targets with script-capable schemes +# (javascript:, vbscript:, data: other than images) are dropped, and raw +# HTML in the content goes through `Sanitize` (only `Sanitize::ALLOWED_TAGS`, +# each with a plain class="..." at most, survive; everything else is +# escaped to literal text). Anyone allowed to edit a page would otherwise +# be able to run script in every reader's browser. struct Fluence::Markdown MARKD_OPTIONS = Markd::Options.new(gfm: true, autolink: true, tagfilter: true, emoji: true, safe: true) + # Allowlist sanitizer for the raw HTML markd's `html_block`/`html_inline` + # nodes carry verbatim (`safe: true` would otherwise drop it entirely). + # Lets page content use icon fonts (``), lay + # out custom structure (headings, lists, article/div sections), and + # apply theme CSS classes to any of it, without opening the door to + # `