diff options
Diffstat (limited to 'pysite')
| -rw-r--r-- | pysite/constants.py | 6 | ||||
| -rw-r--r-- | pysite/database.py | 3 | ||||
| -rw-r--r-- | pysite/views/wiki/edit.py | 133 | ||||
| -rw-r--r-- | pysite/views/wiki/history/compare.py | 51 | ||||
| -rw-r--r-- | pysite/views/wiki/history/show.py | 27 | 
5 files changed, 212 insertions, 8 deletions
| diff --git a/pysite/constants.py b/pysite/constants.py index 737f3a7a..ca18a288 100644 --- a/pysite/constants.py +++ b/pysite/constants.py @@ -80,3 +80,9 @@ DATADOG_PORT = int(environ.get("DATADOG_PORT") or 0)  # CSRF  CSRF = CSRFProtect() + +# GitHub Token +GITHUB_TOKEN = environ.get("GITHUB_TOKEN") or None + +# Audit Webhook +WIKI_AUDIT_WEBHOOK = environ.get("WIKI_AUDIT_WEBHOOK") or None diff --git a/pysite/database.py b/pysite/database.py index 18a9dc0d..8903fbf4 100644 --- a/pysite/database.py +++ b/pysite/database.py @@ -18,7 +18,8 @@ ALL_TABLES = {      "oauth_data": "id",      "tags": "tag_name",      "users": "user_id", -    "wiki": "slug" +    "wiki": "slug", +    "wiki_revisions": "id"  } diff --git a/pysite/views/wiki/edit.py b/pysite/views/wiki/edit.py index 9eb50c9e..9cff9da0 100644 --- a/pysite/views/wiki/edit.py +++ b/pysite/views/wiki/edit.py @@ -1,10 +1,13 @@  # coding=utf-8 -from flask import request, url_for +import datetime +import difflib + +import requests +from flask import redirect, request, url_for  from werkzeug.exceptions import BadRequest -from werkzeug.utils import redirect  from pysite.base_route import RouteView -from pysite.constants import EDITOR_ROLES +from pysite.constants import DEBUG_MODE, EDITOR_ROLES, GITHUB_TOKEN, WIKI_AUDIT_WEBHOOK  from pysite.decorators import csrf, require_roles  from pysite.mixins import DBMixin  from pysite.rst import render @@ -14,6 +17,7 @@ class EditView(RouteView, DBMixin):      path = "/edit/<path:page>"  # "path" means that it accepts slashes      name = "edit"      table_name = "wiki" +    revision_table_name = "wiki_revisions"      @require_roles(*EDITOR_ROLES)      def get(self, page): @@ -24,16 +28,37 @@ class EditView(RouteView, DBMixin):          obj = self.db.get(self.table_name, page)          if obj: -            rst = obj["rst"] -            title = obj["title"] -            preview = obj["html"] +            rst = obj.get("rst", "") +            title = obj.get("title", "") +            preview = obj.get("html", preview) + +            if obj.get("lock_expiry") and obj.get("lock_user") != self.user_data.get("user_id"): +                lock_time = datetime.datetime.fromtimestamp(obj["lock_expiry"]) +                if datetime.datetime.utcnow() < lock_time: +                    return self.render("wiki/page_in_use.html", page=page) + +        lock_expiry = datetime.datetime.utcnow() + datetime.timedelta(minutes=5) + +        if not DEBUG_MODE:  # If we are in debug mode we have no user logged in, therefore we can skip locking +            self.db.insert( +                self.table_name, +                { +                    "slug": page, +                    "lock_expiry": lock_expiry.timestamp(), +                    "lock_user": self.user_data.get("user_id") +                }, +                conflict="update" +            )          return self.render("wiki/page_edit.html", page=page, rst=rst, title=title, preview=preview)      @require_roles(*EDITOR_ROLES)      @csrf      def post(self, page): -        rst = request.form["rst"] +        rst = request.form.get("rst") + +        if not rst: +            raise BadRequest()          if not rst.strip():              raise BadRequest() @@ -48,10 +73,104 @@ class EditView(RouteView, DBMixin):              "headers": rendered["headers"]          } +        self.audit_log(page, obj) +          self.db.insert(              self.table_name,              obj,              conflict="replace"          ) +        # Add the post to the revisions table +        revision_payload = { +            "slug": page, +            "post": obj, +            "date": datetime.datetime.utcnow().timestamp(), +            "user": self.user_data.get("user_id") +        } + +        del revision_payload["post"]["slug"] + +        self.db.insert(self.revision_table_name, revision_payload) +          return redirect(url_for("wiki.page", page=page), code=303)  # Redirect, ensuring a GET + +    @require_roles(*EDITOR_ROLES) +    @csrf +    def patch(self, page): +        current = self.db.get(self.table_name, page) +        if not current: +            return "", 404 + +        if current.get("lock_expiry"):  # If there is a lock present + +            # If user patching is not the user with the lock end here +            if current["lock_user"] != self.user_data.get("user_id"): +                return "", 400 +            new_lock = datetime.datetime.utcnow() + datetime.timedelta(minutes=5)  # New lock time, 5 minutes in future +            self.db.insert(self.table_name, { +                "slug": page, +                "lock_expiry": new_lock.timestamp() +            }, conflict="update")  # Update with new lock time +        return "", 204 + +    def audit_log(self, page, obj): +        if WIKI_AUDIT_WEBHOOK:  # If the audit webhook is not configured there is no point processing the diff +            before = self.db.get(self.table_name, page) +            if not before:  # If this is a new page, before will be None +                before = [] +            else: +                if before.get("rst") is None: +                    before = [] +                else: +                    before = before["rst"].splitlines(keepends=True) +                    if len(before) == 0: +                        pass +                    else: +                        if not before[-1].endswith("\n"): +                            before[-1] += "\n"  # difflib sometimes messes up if a newline is missing on last line + +            after = obj['rst'].splitlines(keepends=True) or [""] + +            if not after[-1].endswith("\n"): +                after[-1] += "\n"  # Does the same thing as L57 + +            diff = difflib.unified_diff(before, after, fromfile="before.rst", tofile="after.rst") +            diff = "".join(diff) + +            gist_payload = { +                "description": f"Changes to: {obj['title']}", +                "public": False, +                "files": { +                    "changes.md": { +                        "content": f"```diff\n{diff}\n```" +                    } +                } +            } + +            headers = { +                "Authorization": f"token {GITHUB_TOKEN}", +                "User-Agent": "Discord Python Wiki (https://github.com/discord-python)" +            } + +            gist = requests.post("https://api.github.com/gists", +                                 json=gist_payload, +                                 headers=headers) + +            audit_payload = { +                "username": "Wiki Updates", +                "embeds": [ +                    { +                        "title": "Page Edit", +                        "description": f"**{obj['title']}** was edited by **{self.user_data.get('username')}**" +                                       f".\n\n[View diff]({gist.json().get('html_url')})", +                        "color": 4165079, +                        "timestamp": datetime.datetime.utcnow().isoformat(), +                        "thumbnail": { +                            "url": "https://pythondiscord.com/static/logos/logo_discord.png" +                        } +                    } +                ] +            } + +            requests.post(WIKI_AUDIT_WEBHOOK, json=audit_payload) diff --git a/pysite/views/wiki/history/compare.py b/pysite/views/wiki/history/compare.py new file mode 100644 index 00000000..46dad8e9 --- /dev/null +++ b/pysite/views/wiki/history/compare.py @@ -0,0 +1,51 @@ +# coding=utf-8 +import difflib + +from pygments import highlight +from pygments.formatters import HtmlFormatter +from pygments.lexers import DiffLexer +from werkzeug.exceptions import BadRequest, NotFound + +from pysite.base_route import RouteView +from pysite.mixins import DBMixin + + +class CompareView(RouteView, DBMixin): +    path = "/history/compare/<string:first_rev>/<string:second_rev>" +    name = "history.compare" + +    table_name = "wiki_revisions" +    table_primary_key = "id" + +    def get(self, first_rev, second_rev): +        before = self.db.get(self.table_name, first_rev) +        after = self.db.get(self.table_name, second_rev) + +        if not (before and after): +            raise NotFound() + +        if before["date"] > after["date"]:  # Check whether the before was created after the after +            raise BadRequest() + +        if before["id"] == after["id"]:  # The same revision has been requested +            raise BadRequest() + +        before_text = before["post"]["rst"] +        after_text = after["post"]["rst"] + +        if not before_text.endswith("\n"): +            before_text += "\n" + +        if not after_text.endswith("\n"): +            after_text += "\n" + +        before_text = before_text.splitlines(keepends=True) +        after_text = after_text.splitlines(keepends=True) + +        if not before["slug"] == after["slug"]: +            raise BadRequest()  # The revisions are not from the same post + +        diff = difflib.unified_diff(before_text, after_text, fromfile=f"{first_rev}.rst", tofile=f"{second_rev}.rst") +        diff = "".join(diff) +        diff = highlight(diff, DiffLexer(), HtmlFormatter()) +        return self.render("wiki/compare_revision.html", title=after["post"]["title"], diff=diff) diff --git a/pysite/views/wiki/history/show.py b/pysite/views/wiki/history/show.py new file mode 100644 index 00000000..9580816f --- /dev/null +++ b/pysite/views/wiki/history/show.py @@ -0,0 +1,27 @@ +# coding=utf-8 +import datetime + +from werkzeug.exceptions import NotFound + +from pysite.base_route import RouteView +from pysite.mixins import DBMixin + + +class RevisionsListView(RouteView, DBMixin): +    path = "/history/show/<string:page>" +    name = "history.show" + +    table_name = "wiki_revisions" +    table_primary_key = "id" + +    def get(self, page): +        results = self.db.filter(self.table_name, lambda revision: revision["slug"] == page) +        if len(results) == 0: +            raise NotFound() + +        for result in results: +            ts = datetime.datetime.fromtimestamp(result["date"]) +            result["pretty_time"] = ts.strftime("%d %b %Y") + +        results = sorted(results, key=lambda revision: revision["date"], reverse=True) +        return self.render("wiki/revision_list.html", page=page, revisions=results), 200 | 
