src/backend/talk.py \
src/backend/tracker.py \
src/backend/util.py \
+ src/backend/wiki.py \
src/backend/zeiterfassung.py
backenddir = $(pythondir)/ipfire
src/web/newsletter.py \
src/web/nopaste.py \
src/web/people.py \
- src/web/ui_modules.py
+ src/web/ui_modules.py \
+ src/web/wiki.py
webdir = $(backenddir)/web
templates_staticdir = $(templatesdir)/static
+templates_wiki_DATA = \
+ src/templates/wiki/404.html \
+ src/templates/wiki/base.html \
+ src/templates/wiki/edit.html \
+ src/templates/wiki/page.html \
+ src/templates/wiki/recent-changes.html \
+ src/templates/wiki/revisions.html
+
+templates_wikidir = $(templatesdir)/wiki
+
+templates_wiki_modules_DATA = \
+ src/templates/wiki/modules/list.html \
+ src/templates/wiki/modules/navbar.html
+
+templates_wiki_modulesdir = $(templates_wikidir)/modules
+
# ------------------------------------------------------------------------------
SCSS_FILES = \
from . import talk
from . import blog
+from . import wiki
from . import zeiterfassung
DEFAULT_CONFIG = io.StringIO("""
self.talk = talk.Talk(self)
self.blog = blog.Blog(self)
+ self.wiki = wiki.Wiki(self)
self.zeiterfassung = zeiterfassung.ZeiterfassungClient(self)
def read_config(self, configfile):
def _render_text(self, text, lang="markdown"):
if lang == "markdown":
return markdown2.markdown(text, link_patterns=link_patterns,
- extras=["footnotes", "link-patterns", "wiki-tables"])
+ extras=[
+ "code-friendly",
+ "cuddled-lists",
+ "fenced-code-blocks",
+ "footnotes",
+ "header-ids",
+ "link-patterns",
+ "tables",
+ ])
elif lang == "textile":
return textile.textile(text)
--- /dev/null
+#!/usr/bin/python3
+
+import logging
+import os.path
+import re
+
+from . import misc
+from .decorators import *
+
+class Wiki(misc.Object):
+ def _get_pages(self, query, *args):
+ res = self.db.query(query, *args)
+
+ for row in res:
+ yield Page(self.backend, row.id, data=row)
+
+ def _get_page(self, query, *args):
+ res = self.db.get(query, *args)
+
+ if res:
+ return Page(self.backend, res.id, data=res)
+
+ def get_page_title(self, page, default=None):
+ doc = self.get_page(page)
+ if doc:
+ return doc.title
+
+ return default
+
+ def get_page(self, page, revision=None):
+ page = Page.sanitise_page_name(page)
+ assert page
+
+ if revision:
+ return self._get_page("SELECT * FROM wiki WHERE page = %s \
+ AND timestamp = %s", page, revision)
+ else:
+ return self._get_page("SELECT * FROM wiki WHERE page = %s \
+ ORDER BY timestamp DESC LIMIT 1", page)
+
+ def get_recent_changes(self, limit=None):
+ return self._get_pages("SELECT * FROM wiki \
+ WHERE timestamp >= NOW() - INTERVAL '4 weeks' \
+ ORDER BY timestamp DESC LIMIT %s", limit)
+
+ def create_page(self, page, author, content, changes=None, address=None):
+ page = Page.sanitise_page_name(page)
+
+ return self._get_page("INSERT INTO wiki(page, author_uid, markdown, changes, address) \
+ VALUES(%s, %s, %s, %s, %s) RETURNING *", page, author.uid, content or None, changes, address)
+
+ def delete_page(self, page, author, **kwargs):
+ # Do nothing if the page does not exist
+ if not self.get_page(page):
+ return
+
+ # Just creates a blank last version of the page
+ self.create_page(page, author=author, content=None, **kwargs)
+
+ @staticmethod
+ def _split_url(url):
+ parts = list(e for e in url.split("/") if e)
+
+ num_parts = len(parts)
+ for i in range(num_parts):
+ yield "/".join(parts[:i])
+
+ def make_breadcrumbs(self, url):
+ for part in self._split_url(url):
+ title = self.get_page_title(part, os.path.basename(part))
+
+ yield ("/%s" % part, title)
+
+
+class Page(misc.Object):
+ def init(self, id, data=None):
+ self.id = id
+ self.data = data
+
+ def __lt__(self, other):
+ if isinstance(other, self.__class__):
+ if self.page == other.page:
+ return self.timestamp < other.timestamp
+
+ return self.page < other.page
+
+ @staticmethod
+ def sanitise_page_name(page):
+ if not page:
+ return "/"
+
+ # Make sure that the page name does NOT end with a /
+ if page.endswith("/"):
+ page = page[:-1]
+
+ # Make sure the page name starts with a /
+ if not page.startswith("/"):
+ page = "/%s" % page
+
+ # Remove any double slashes
+ page = page.replace("//", "/")
+
+ return page
+
+ @property
+ def url(self):
+ return self.page
+
+ @property
+ def page(self):
+ return self.data.page
+
+ @property
+ def title(self):
+ return self._title or self.page[1:]
+
+ @property
+ def _title(self):
+ if not self.markdown:
+ return
+
+ # Find first H1 headline in markdown
+ markdown = self.markdown.splitlines()
+
+ m = re.match(r"^# (.*)( #)?$", markdown[0])
+ if m:
+ return m.group(1)
+
+ @lazy_property
+ def author(self):
+ if self.data.author_uid:
+ return self.backend.accounts.get_by_uid(self.data.author_uid)
+
+ def _render(self, text):
+ logging.debug("Rendering %s" % self)
+
+ # Borrow this from the blog
+ return self.backend.blog._render_text(text, lang="markdown")
+
+ @property
+ def markdown(self):
+ return self.data.markdown
+
+ @property
+ def html(self):
+ return self.data.html or self._render(self.markdown)
+
+ @property
+ def timestamp(self):
+ return self.data.timestamp
+
+ def was_deleted(self):
+ return self.markdown is None
+
+ @lazy_property
+ def breadcrumbs(self):
+ return self.backend.wiki.make_breadcrumbs(self.page)
+
+ def get_latest_revision(self):
+ revisions = self.get_revisions()
+
+ # Return first object
+ for rev in revisions:
+ return rev
+
+ def get_revisions(self):
+ return self.backend.wiki._get_pages("SELECT * FROM wiki \
+ WHERE page = %s ORDER BY timestamp DESC", self.page)
+
+ @property
+ def changes(self):
+ return self.data.changes
+
+ # Sidebar
+
+ @lazy_property
+ def sidebar(self):
+ parts = self.page.split("/")
+
+ while parts:
+ sidebar = self.backend.wiki.get_page(os.path.join(*parts, "sidebar"))
+ if sidebar:
+ return sidebar
+
+ parts.pop()
// Progress
$progress-height: 1rem * $line-height-base;
+
+// Breadcrumbs
+$breadcrumb-bg: white;
@import "../bootstrap/scss/navbar";
@import "../bootstrap/scss/card";
@import "../bootstrap/scss/pagination";
+@import "../bootstrap/scss/breadcrumb";
@import "../bootstrap/scss/badge";
@import "../bootstrap/scss/alert";
@import "../bootstrap/scss/progress";
}
}
+.wiki-content {
+ h1, h2, h3, h4, h5, h6 {
+ font-weight: $headings-font-weight;
+ line-height: $headings-line-height;
+ margin-bottom: 0.25rem;
+ }
+
+ h1 {
+ font-size: $h4-font-size;
+ }
+
+ h2 {
+ font-size: $h5-font-size;
+ }
+
+ h3, h4, h5, h6 {
+ font-size: $h6-font-size;
+ }
+
+ img {
+ @include img-fluid;
+
+ // Center all images
+ display: block;
+ margin-left: auto;
+ margin-right: auto;
+
+ // Add some extra margin to the top & bottom
+ padding: ($spacer * 2) 0 ($spacer * 2) 0;
+ }
+
+ blockquote {
+ @extend .blockquote;
+ }
+
+ table {
+ @extend .table;
+ @extend .table-sm;
+ }
+}
+
hr.divider {
border-color: rgba($dark, .15);
margin-top: 2rem;
{{ _("Mirrors") }}
{% elif hostname == "people.ipfire.org" %}
{{ _("People") }}
+ {% elif hostname == "wiki.ipfire.org" %}
+ {{ _("Wiki") }}
{% end %}
</a>
</form>
</div>
{% end %}
+ {% elif hostname == "wiki.ipfire.org" %}
+ <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbar"
+ aria-controls="navbar" aria-expanded="false" aria-label="Toggle navigation">
+ <span class="fas fa-bars"></span>
+ </button>
+
+ <div class="collapse navbar-collapse" id="navbar">
+ <ul class="navbar-nav ml-auto mr-3">
+ <li class="nav-item">
+ <a class="nav-link {% if request.path == "/recent-changes" %}active{% end %}" href="/recent-changes">
+ {{ _("Recent Changes") }}
+ </a>
+ </li>
+ </ul>
+
+ <form class="form-inline my-2 my-lg-0" action="/search" method="GET">
+ <input class="form-control form-control-sm" type="search" name="q"
+ placeholder="{{ _("Search...") }}" aria-label="{{ _("Search") }}" value="{% try %}{{ q }}{% except %}{% end %}">
+ </form>
+
+ <a class="btn btn-primary ml-lg-2" href="https://www.ipfire.org/donate">
+ <span class="fas fa-heart"></span> {{ _("Donate") }}
+ </a>
+ </div>
{% end %}
{% end block %}
</div>
<div class="col">
<ul class="list-unstyled">
<li>
- <a href="https://www.ipfire.org/features">{{ _("Features") }}</a>
+ <a href="/features">{{ _("Features") }}</a>
</li>
<li>
- <a href="https://downloads.ipfire.org">{{ _("Download") }}</a>
+ <a href="/download">{{ _("Download") }}</a>
</li>
<li>
- <a href="https://www.ipfire.org/support">{{ _("Support") }}</a>
+ <a href="/support">{{ _("Support") }}</a>
</li>
</ul>
</div>
</li>
<li>
- <a href="https://www.ipfire.org/legal">{{ _("Legal") }}</a>
+ <a href="/legal">{{ _("Legal") }}</a>
</li>
</ul>
</div>
</div>
<div class="col-12 col-lg-4 text-center mb-4">
- <a class="btn btn-primary btn-lg px-4 my-4" href="https://www.ipfire.org/donate">
+ <a class="btn btn-primary btn-lg px-4 my-4" href="/donate">
<span class="fas fa-heart"></span> {{ _("Donate") }}
</a>
<div class="container d-flex justify-content-between">
© {{ year }} - IPFire - {{ _("The Open Source Firewall") }}
- {% if not current_user and hostname in ("blog.ipfire.org", "nopaste.ipfire.org") %}
+ {% if not current_user and hostname in ("blog.ipfire.org", "nopaste.ipfire.org", "wiki.ipfire.org") %}
<a href="/login">{{ _("Login") }}</a>
{% elif current_user %}
<span>
--- /dev/null
+{% extends "../base.html" %}
+
+{% block title %}{{ _("Page Not Found") }}{% end block %}
+
+{% block container %}
+ <section>
+ <div class="container">
+ <div class="row justify-content-center mt-5">
+ <div class="col col-md-7">
+ <h5 class="mb-0">{{ _("Error 404") }}</h5>
+
+ <h2>{{ _("This Page Does Not Exist") }}</h2>
+
+ <p>
+ {{ _("This wiki page does not exist, yet.") }}
+ </p>
+
+ <a class="btn btn-primary btn-block" href="{{ request.path }}?action=edit">
+ {{ _("Create Now") }}
+ </a>
+ </div>
+ </div>
+ </div>
+ </section>
+{% end block %}
--- /dev/null
+{% extends "../base.html" %}
+
+{% block content %}
+ <div class="row">
+ <div class="col-12 col-lg-3 d-none d-lg-block">
+ {% block sidebar %}{% end block %}
+ </div>
+
+ <div class="col-12 col-lg-9">
+ {% block main %}{% end block %}
+ </div>
+ </div>
+{% end block %}
--- /dev/null
+{% extends "base.html" %}
+
+{% block title %}{% if page %}{{ _("Edit %s") % page.title }}{% else %}{{ _("Create A New Page") }}{% end %}{% end block %}
+
+{% block main %}
+ <div class="card">
+ <div class="card-body">
+ <h4 class="card-title">
+ {% if page %}{{ _("Edit %s") % page.title }}{% else %}{{ _("Create A New Page") }}{% end %}
+ </h4>
+
+ <form action="" method="POST">
+ {% raw xsrf_form_html() %}
+
+ <input type="hidden" name="page" value="{{ request.path }}">
+
+ <div class="form-group">
+ <textarea class="form-control" rows="16" name="content" placeholder="{{ _("Text") }}"
+ >{% if page and page.markdown %}{{ page.markdown }}{% end %}</textarea>
+ </div>
+
+ <div class="form-group row">
+ <label class="col-sm-3 col-form-label">{{ _("What has changed?") }}</label>
+ <div class="col-sm-9">
+ <input type="text" class="form-control" name="changes" required>
+ </div>
+ </div>
+
+ <button type="submit" class="btn btn-primary btn-block">
+ {% if page %}{{ _("Save Page") }}{% else %}{{ _("Create Page") }}{% end %}
+ </button>
+ </form>
+ </div>
+ </div>
+{% end block %}
--- /dev/null
+{% for page in pages %}
+ <strong class="mb-0">
+ {% if show_breadcrumbs %}
+ {% for url, title in page.breadcrumbs %}
+ <a href="{{ url }}">{{ title }}</a> /
+ {% end %}
+ {% end %}
+
+ <a href="{{ page.url }}{% if link_revision %}?revision={{ page.timestamp.isoformat() }}{% end %}">{{ page.title }}</a>
+ </strong>
+
+ <p class="text-muted small">
+ {{ locale.format_date(page.timestamp, shorter=True, relative=False) }}
+
+ {% if page.author %}
+ {{ _("by") }}
+ <a href="/users/{{ page.author.uid }}">{{ page.author }}</a>
+ {% end %}
+
+ {% if show_changes and page.changes %}
+ • {{ page.changes }}
+ {% end %}
+ </p>
+{% end %}
--- /dev/null
+<ol class="breadcrumb">
+ {% for page, title in breadcrumbs %}
+ <li class="breadcrumb-item">
+ <a href="{{ page }}">{{ title }}</a>
+ </li>
+ {% end %}
+
+ <li class="breadcrumb-item {% if not suffix %}active{% end %}">
+ {{ page_title }}
+ </li>
+
+ {% if suffix %}
+ <li class="breadcrumb-item active">{{ suffix }}</li>
+ {% end %}
+</ol>
--- /dev/null
+{% extends "base.html" %}
+
+{% block title %}{{ page.title }}{% end block %}
+
+{% block sidebar %}
+ {% if page.sidebar %}
+ <div class="wiki-content">
+ {% raw page.sidebar.html %}
+ </div>
+ {% end %}
+{% end block %}
+
+{% block main %}
+ {% if not request.path == "/" %}
+ {% module WikiNavbar(page) %}
+ {% end %}
+
+ <div class="card mb-3">
+ <div class="card-body wiki-content">
+ {% raw page.html %}
+ </div>
+ </div>
+
+ <p class="small text-right">
+ {{ _("Last modified %s") % locale.format_date(page.timestamp) }}
+
+ {% if page.author %}
+ •
+
+ <a href="/users/{{ page.author.uid }}">
+ {{ page.author }}
+ </a>
+ {% end %}
+ </p>
+{% end block %}
--- /dev/null
+{% extends "../base.html" %}
+
+{% block title %}{{ _("Recent Changes") }}{% end block %}
+
+{% block content %}
+ <section>
+ <div class="container">
+ <div class="row">
+ <div class="col col-lg-6">
+ <h1>{{ _("Recent Changes") }}</h1>
+ </div>
+ </div>
+ </div>
+ </section>
+
+ <div class="card">
+ <div class="card-body">
+ {% module WikiList(recent_changes, show_changes=True) %}
+ </div>
+ </div>
+{% end block %}
--- /dev/null
+{% extends "page.html" %}
+
+{% block title %}{{ _("Revisions of %s") % page.title }}{% end block %}
+
+{% block main %}
+ {% if not request.path == "/" %}
+ {% module WikiNavbar(page) %}
+ {% end %}
+
+ <div class="card mb-3">
+ <div class="card-body">
+ <h4 class="card-title">{{ _("Revisions of %s") % page.title }}</h4>
+
+ {% module WikiList(page.get_revisions(), show_breadcrumbs=False, link_revision=True) %}
+ </div>
+ </div>
+{% end block %}
from . import nopaste
from . import people
from . import ui_modules
+from . import wiki
class Application(tornado.web.Application):
def __init__(self, config, **kwargs):
"FireinfoDeviceAndGroupsTable"
: fireinfo.DeviceAndGroupsTableModule,
+ # Wiki
+ "WikiNavbar" : wiki.WikiNavbarModule,
+ "WikiList" : wiki.WikiListModule,
+
# Misc
"Map" : ui_modules.MapModule,
"ProgressBar" : ui_modules.ProgressBarModule,
(r"/users/(\w+)/sip", people.SIPHandler),
] + authentication_handlers)
+ # wiki.ipfire.org
+ self.add_handlers(r"wiki(\.dev)?\.ipfire\.org",
+ authentication_handlers + [
+
+ # Handlers
+ (r"/recent\-changes", wiki.RecentChangesHandler),
+ (r"/search", wiki.SearchHandler),
+
+ # Render pages
+ (r"([A-Za-z0-9\-_\/]+)?", wiki.PageHandler),
+ ])
+
# ipfire.org
self.add_handlers(r"ipfire\.org", [
(r".*", tornado.web.RedirectHandler, { "url" : "https://www.ipfire.org" })
--- /dev/null
+#!/usr/bin/python3
+
+import tornado.web
+
+from . import auth
+from . import base
+from . import ui_modules
+
+class PageHandler(auth.CacheMixin, base.BaseHandler):
+ @property
+ def action(self):
+ return self.get_argument("action", None)
+
+ def write_error(self, status_code, **kwargs):
+ # Render a custom page for 404
+ if status_code == 404:
+ self.render("wiki/404.html", **kwargs)
+ return
+
+ # Otherwise raise this to one layer above
+ super().write_error(status_code, **kwargs)
+
+ @tornado.web.removeslash
+ def get(self, page):
+ # Check if we are asked to render a certain revision
+ revision = self.get_argument("revision", None)
+
+ # Fetch the wiki page
+ page = self.backend.wiki.get_page(page, revision=revision)
+
+ # Edit
+ if self.action == "edit":
+ if not self.current_user:
+ raise tornado.web.HTTPError(401)
+
+ # Empty page if it was deleted
+ if page and page.was_deleted():
+ page = None
+
+ # Render page
+ self.render("wiki/edit.html", page=page)
+ return
+
+ # Revisions
+ elif self.action == "revisions":
+ self.render("wiki/revisions.html", page=page)
+ return
+
+ # If the page does not exist, we send 404
+ if not page or page.was_deleted():
+ raise tornado.web.HTTPError(404)
+
+ # Fetch the latest revision
+ latest_revision = page.get_latest_revision()
+
+ # Render page
+ self.render("wiki/page.html", page=page, latest_revision=latest_revision)
+
+ @tornado.web.authenticated
+ def post(self, page):
+ content = self.get_argument("content", None)
+ changes = self.get_argument("changes")
+
+ # Create a new page in the database
+ with self.db.transaction():
+ page = self.backend.wiki.create_page(page,
+ self.current_user, content, changes=changes, address=self.get_remote_ip())
+
+ # Redirect back
+ if page.was_deleted():
+ self.redirect("/")
+ else:
+ self.redirect(page.url)
+
+
+class SearchHandler(auth.CacheMixin, base.BaseHandler):
+ @base.blacklisted
+ def get(self):
+ q = self.get_argument("q")
+
+ pages = self.backend.wiki.search(q, limit=50)
+ if not pages:
+ raise tornado.web.HTTPError(404, "Nothing found")
+
+ self.render("wiki/search-results.html", q=q, pages=pages)
+
+
+class RecentChangesHandler(auth.CacheMixin, base.BaseHandler):
+ def get(self):
+ recent_changes = self.backend.wiki.get_recent_changes(limit=50)
+
+ self.render("wiki/recent-changes.html", recent_changes=recent_changes)
+
+
+class WikiListModule(ui_modules.UIModule):
+ def render(self, pages, link_revision=False, show_breadcrumbs=True, show_changes=False):
+ return self.render_string("wiki/modules/list.html", link_revision=link_revision,
+ pages=pages, show_breadcrumbs=show_breadcrumbs, show_changes=show_changes)
+
+
+class WikiNavbarModule(ui_modules.UIModule):
+ def render(self, page, suffix=None):
+ breadcrumbs = self.backend.wiki.make_breadcrumbs(page.url)
+
+ return self.render_string("wiki/modules/navbar.html",
+ breadcrumbs=breadcrumbs, page_title=page.title, suffix=suffix)