]> git.ipfire.org Git - ipfire.org.git/commitdiff
Implement a basic wiki
authorMichael Tremer <michael.tremer@ipfire.org>
Mon, 12 Nov 2018 15:07:34 +0000 (15:07 +0000)
committerMichael Tremer <michael.tremer@ipfire.org>
Mon, 12 Nov 2018 15:07:34 +0000 (15:07 +0000)
Signed-off-by: Michael Tremer <michael.tremer@ipfire.org>
Makefile.am
src/backend/base.py
src/backend/wiki.py [new file with mode: 0644]
src/templates/base.html
src/templates/wiki/base.html [new file with mode: 0644]
src/templates/wiki/page.html [new file with mode: 0644]
src/web/__init__.py
src/web/wiki.py [new file with mode: 0644]

index 6867394d74e91cb811b6f345f617dea282cf29c6..621c610d7381ed3d8d7e9bb3a1cf2f7e2d449a35 100644 (file)
@@ -66,6 +66,7 @@ backend_PYTHON = \
        src/backend/talk.py \
        src/backend/tracker.py \
        src/backend/util.py \
+       src/backend/wiki.py \
        src/backend/zeiterfassung.py
 
 backenddir = $(pythondir)/ipfire
@@ -88,7 +89,8 @@ web_PYTHON = \
        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
 
@@ -251,6 +253,12 @@ templates_static_DATA = \
 
 templates_staticdir = $(templatesdir)/static
 
+templates_wiki_DATA = \
+       src/templates/wiki/base.html \
+       src/templates/wiki/page.html
+
+templates_wikidir = $(templatesdir)/wiki
+
 # ------------------------------------------------------------------------------
 
 SCSS_FILES = \
index 0507d5cd86073b36d570e8482af7f90b1536600d..9e4ae21b2578626d39758f2362e4e7afb0414b7b 100644 (file)
@@ -17,6 +17,7 @@ from . import settings
 from . import talk
 
 from . import blog
+from . import wiki
 from . import zeiterfassung
 
 DEFAULT_CONFIG = io.StringIO("""
@@ -54,6 +55,7 @@ class Backend(object):
                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):
diff --git a/src/backend/wiki.py b/src/backend/wiki.py
new file mode 100644 (file)
index 0000000..a5635be
--- /dev/null
@@ -0,0 +1,159 @@
+#!/usr/bin/python3
+
+import logging
+import markdown2
+import re
+
+from . import misc
+from .decorators import *
+
+# Used to automatically link some things
+link_patterns = (
+       # Find bug reports
+       (re.compile(r"(?:#(\d+))", re.I), r"https://bugzilla.ipfire.org/show_bug.cgi?id=\1"),
+
+       # Email Addresses
+       (re.compile(r"([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)"), r"mailto:\1"),
+
+       # CVE Numbers
+       (re.compile(r"(?:CVE)[\s\-](\d{4}\-\d+)"), r"https://cve.mitre.org/cgi-bin/cvename.cgi?name=\1"),
+)
+
+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, page, revision=None):
+               page = Page.sanitise_page_name(page)
+               assert page
+
+               if revision:
+                       res = self.db.get("SELECT * FROM wiki WHERE page = %s \
+                               AND timestamp = %s", page, revision)
+               else:
+                       res = self.db.get("SELECT * FROM wiki WHERE page = %s \
+                               ORDER BY timestamp DESC LIMIT 1", page)
+
+               if res:
+                       return Page(self.backend, res.id, data=res)
+
+       def get_recent_changes(self):
+               return self._get_pages("SELECT * FROM wiki \
+                       WHERE timestamp >= NOW() - INTERVAL '4 weeks' ORDER BY timestamp DESC")
+
+       def create_page(self, page, author, markdown):
+               page = Page.sanitise_page_name(page)
+
+               res = self.db.get("INSERT INTO wiki(page, author_id, markdown) \
+                       VALUES(%s, %s, %s) RETURNING id", page, author.id, markdown)
+
+               if res:
+                       return self.get_page_by_id(res.id)
+
+       def delete_page(self, page, author):
+               # 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, None)
+
+       @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 "/%s" % 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)
+
+       def _render(self, text):
+               logging.debug("Rendering %s" % self)
+
+               return markdown2.markdown(text, link_patterns=link_patterns,
+                       extras=["footnotes", "link-patterns", "wiki-tables"])
+
+       @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):
+               return self.backend.wiki.get_page(self.page)
index ffe6fb67dfbb418e9bb60ac5a838c2a69c100300..806b98e784a1c8071ca09e0d0ac195f020230ac1 100644 (file)
@@ -29,6 +29,8 @@
                                                {{ _("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">
+                                                       <form class="form-inline ml-lg-auto 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>
diff --git a/src/templates/wiki/base.html b/src/templates/wiki/base.html
new file mode 100644 (file)
index 0000000..dd35246
--- /dev/null
@@ -0,0 +1,13 @@
+{% 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 %}
diff --git a/src/templates/wiki/page.html b/src/templates/wiki/page.html
new file mode 100644 (file)
index 0000000..575863e
--- /dev/null
@@ -0,0 +1,11 @@
+{% extends "base.html" %}
+
+{% block title %}{{ page.title }}{% end block %}
+
+{% block main %}
+       <div class="card">
+               <div class="card-body">
+                       {% raw page.html %}
+               </div>
+       </div>
+{% end block %}
index d7c2407bf93bf9a5b2769c103bf6f11701595a2b..1c054b8f8830b94d584483c1bda685fd9464e088 100644 (file)
@@ -27,6 +27,7 @@ from . import newsletter
 from . import nopaste
 from . import people
 from . import ui_modules
+from . import wiki
 
 class Application(tornado.web.Application):
        def __init__(self, config, **kwargs):
@@ -275,6 +276,17 @@ class Application(tornado.web.Application):
                        (r"/users/(\w+)/sip", people.SIPHandler),
                ]  + authentication_handlers)
 
+               # wiki.ipfire.org
+               self.add_handlers(r"wiki(\.dev)?\.ipfire\.org",
+                       authentication_handlers + [
+
+                       # Deliver static files (CSS, etc.)
+                       #(r"/(static/.*)", tornado.web.StaticFileHandler),
+
+                       (r"/search", wiki.SearchHandler),
+                       (r"([A-Za-z0-9\-_\/]+)?", wiki.PageHandler),
+               ])
+
                # ipfire.org
                self.add_handlers(r"ipfire\.org", [
                        (r".*", tornado.web.RedirectHandler, { "url" : "https://www.ipfire.org" })
diff --git a/src/web/wiki.py b/src/web/wiki.py
new file mode 100644 (file)
index 0000000..e8201f1
--- /dev/null
@@ -0,0 +1,50 @@
+#!/usr/bin/python3
+
+import tornado.web
+
+from . import auth
+from . import base
+
+class PageHandler(auth.CacheMixin, base.BaseHandler):
+       @tornado.web.removeslash
+       def get(self, page):
+               page = self.backend.wiki.get_page(page)
+
+               # 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)
+
+               # Delete the page if content is empty
+               if not content:
+                       with self.db.transaction():
+                               self.backend.wiki.delete_page(page, self.current_user)
+
+                       return self.redirect("/")
+
+               # Create a new page in the database
+               page = self.backend.wiki.create_page(page, self.current_user, content)
+
+               # Redirect
+               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)