Skip to content
Open
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
8 changes: 7 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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

51 changes: 51 additions & 0 deletions config/options.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/ (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/<name>.md" as
# usual; a translation lives alongside it at "pages/<name>.<code>.md"
# and is reached as "?lang=<code>" 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/<name>.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
Expand Down Expand Up @@ -78,6 +122,7 @@ module Fluence

Dir.mkdir_p @datadir
Dir.mkdir_p @metadir
Dir.mkdir_p @themedir
end

getter host : String
Expand All @@ -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
Expand Down
70 changes: 64 additions & 6 deletions spec/controllers_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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 %(<a class="tree-link active" href="/pages/docs/guide">Guide</a>)
body.should contain %(<div class="collapse show" id="tree-docs">)
body.should contain %(<div class="collapse show" id="tree-docs-guide">)
Expand All @@ -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 %(<span class="btn btn-outline-secondary text-nowrap disabled">‹ Prev</span>)
body.should contain %(href="/pages/docs" title="Next: Docs">)

SpecClient.new.get("/sitemap").body.should contain %(<div class="collapse show" id="tree-other">)
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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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 %(<div id="no-attachments" class="small text-body-secondary">None.</div>)
client.get("/pages/no-att").body.should contain %(<div id="no-attachments" class="small text-body-secondary">None.</div>)
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</a>)
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
Expand Down Expand Up @@ -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 <b>bold</b> & \"quoted\" one\n"

body = SpecClient.new.get("/pages/toc-esc").body
body = SpecClient.login("editor", "sekrit123").get("/pages/toc-esc").body
entry = body.match!(/<li><a href="#([^"]*)">(A .*?)<\/a>/)
entry[1].should eq "a-b-bold-/b-quoted-one"
entry[2].should eq "A &lt;b&gt;bold&lt;/b&gt; &amp; &quot;quoted&quot; one"
body.should contain %(<h2 id="#{entry[1]}">)
body.should_not contain "<b>bold</b>"

# Guests get the rendered body (with the heading) but no TOC.
guest = SpecClient.new.get("/pages/toc-esc").body
guest.should contain %(<h2 id="#{entry[1]}">)
guest.should_not contain "<b>bold</b>"
end
end
end
Expand Down
105 changes: 102 additions & 3 deletions spec/markdown_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,116 @@ describe Fluence::Markdown do
Fluence::Markdown.to_html("```\n# not a heading\n```\n").should_not contain "<h1"
end

it "omits raw HTML and script-capable link targets" do
it "escapes disallowed raw HTML and drops script-capable link targets" do
html = Fluence::Markdown.to_html("<img src=x onerror=alert(1)>\n\ntext <b onclick=alert(2)>bold</b>\n")
html.should_not contain "onerror"
html.should_not contain "onclick"
html.should_not contain "<img"
html.should_not contain "<b "
html.should contain "&lt;img src=x onerror=alert(1)&gt;"
html.should contain "text"

html = Fluence::Markdown.to_html("[x](javascript:alert(1)) ![i](data:text/html,x) ![ok](data:image/png;base64,AA==)\n")
html.should_not contain "javascript:"
html.should_not contain "data:text/html"
html.should contain %(src="data:image/png;base64,AA==")
end

it "lets <i>/<span class=\"...\"> through for icons and custom styling" do
html = Fluence::Markdown.to_html(%(An icon <i class="fas fa-running l4"></i> and <span class="highlight">text</span>.))
html.should contain %(<i class="fas fa-running l4"></i>)
html.should contain %(<span class="highlight">text</span>)
end

it "lets headings, lists, and layout containers through as raw HTML" do
html = Fluence::Markdown.to_html(<<-HTML
<div class="callout">
<article>
<h3 class="title">Heading</h3>
<ul><li>one</li><li>two</li></ul>
<ol><li>first</li></ol>
</article>
</div>
HTML
)
html.should contain %(<div class="callout">)
html.should contain "<article>"
html.should contain %(<h3 class="title">Heading</h3>)
html.should contain "<ul><li>one</li><li>two</li></ul>"
html.should contain "<ol><li>first</li></ol>"
html.should contain "</div>"
end

it "still escapes an unsafe tag disguised with a class attribute" do
html = Fluence::Markdown.to_html(%(<img class="x" src=y onerror=alert(1)>))
html.should_not contain "<img"
html.should contain "&lt;img"
end

it "escapes an opening tag carrying an extra attribute besides class" do
html = Fluence::Markdown.to_html(%(<span class="x" onclick="alert(1)">text</span>))
html.should_not contain %(<span class="x" onclick)
html.should contain %(&lt;span class="x" onclick="alert(1)"&gt;)
end

it "leaves links alone when no user is given to check them against" do
Fluence::Markdown.to_html("[Secret](/pages/hidden/secret)")
.should contain %(<a href="/pages/hidden/secret">Secret</a>)
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}</span>"
html.should contain %(<a href="/pages/visible">Visible</a>)
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}</span>"
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}</span>"
html.should contain %(<img src="/media/visible.png" alt="Visible" />)
ensure
Fluence::ACL["guest"].delete "#{Fluence::OPTIONS.media_prefix}/hidden/*"
end
end
end
end

describe Fluence::WikilinkMigration do
Expand Down
9 changes: 6 additions & 3 deletions src/controllers/helpers/page.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 = ""
Expand Down
Loading