diff options
-rw-r--r-- | app_test.py | 21 | ||||
-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 | ||||
-rw-r--r-- | static/js/revision_diff.js | 75 | ||||
-rw-r--r-- | templates/wiki/compare_revision.html | 13 | ||||
-rw-r--r-- | templates/wiki/page_edit.html | 20 | ||||
-rw-r--r-- | templates/wiki/page_in_use.html | 13 | ||||
-rw-r--r-- | templates/wiki/revision_list.html | 36 |
11 files changed, 388 insertions, 10 deletions
diff --git a/app_test.py b/app_test.py index 1e46a73d..20293db9 100644 --- a/app_test.py +++ b/app_test.py @@ -114,6 +114,26 @@ class RootEndpoint(SiteTest): response = self.client.get("/500") self.assertEqual(response.status_code, 500) + def test_wiki_edit(self): + """Test that the wiki edit page redirects to login""" + response = self.client.get("/edit/page", "http://wiki.pytest.local") + self.assertEqual(response.status_code, 302) + + def test_wiki_edit_post_empty_request(self): + """Empty request should redirect to login""" + response = self.client.post("/edit/page", "http://wiki.pytest.local") + self.assertEqual(response.status_code, 302) + + def test_wiki_history(self): + """Test the history show""" + response = self.client.get("/history/show/blahblah-non-existant-page", "http://wiki.pytest.local") + self.assertEqual(response.status_code, 404) # Test that unknown routes 404 + + def test_wiki_diff(self): + """Test whether invalid revision IDs error""" + response = self.client.get("/history/compare/ABC/XYZ", "http://wiki.pytest.local") + self.assertEqual(response.status_code, 404) # Test that unknown revisions 404 + class ApiEndpoints(SiteTest): """ Test cases for the api subdomain """ @@ -236,7 +256,6 @@ class StaffEndpoints(SiteTest): from pysite.views.staff.index import StaffView sv = StaffView() result = sv.get() - print(repr(result.data)) self.assertEqual(result.status_code, 302) # TODO: Do this correctly response = self.client.get('/', app.config['STAFF_SUBDOMAIN']) 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 diff --git a/static/js/revision_diff.js b/static/js/revision_diff.js new file mode 100644 index 00000000..86f30b41 --- /dev/null +++ b/static/js/revision_diff.js @@ -0,0 +1,75 @@ +"use strict"; + +function(){ +let buttons = document.querySelectorAll("td input"); // Fetch all radio buttons +let id_reg = /compare-(before|after)-([\w|-]+)/; // Matches compare-after/before-ID + + +function getRevisionId(element + let e = element.id.match(id_reg); // Match ID with RegExp + return [e[1], e[2]]; // e is in format of [full id, after/before, ID] we only want ID & mode +} + +function getRevision(id) { + let e = revisions.filter((x) => { + return x.id === id; // Filter through all revisions to find the selected one (revisions in declared in the template) + }); + return e[0]; +} + +function radioButtonChecked(element) { + console.log("change detected"); + let id = getRevisionId(element); + let rev = getRevision(id[1]); + if (id[0] === "after"){ + document.querySelector(`#compare-before-${id[1]}`).checked = false; // Deselect the opposite checkbox to the one which has been checked + // because we don't want checking of the same revision + + buttons.forEach(function(e){ + if (getRevisionId(e)[0] === "after" && e.id !== element.id) { // Deselect all checkboxes in the same row + e.checked = false; + } + }) + } else { // This else does the same as above but for the before column + document.querySelector(`#compare-after-${id[1]}`).checked = false; + buttons.forEach(function(e){ + if (getRevisionId(e)[0] === "before" && e.id !== element.id) { + e.checked = false; + } + + if (getRevisionId(e)[0] === "after") { // This makes sure that you do not compare a new revision with an old one + let tmprev = getRevision(getRevisionId(e)[1]) + console.log(tmprev); + if (tmprev.date <= rev.date) { + document.querySelector(`#${e.id}`).setAttribute("disabled", "") + } else { + document.querySelector(`#${e.id}`).removeAttribute("disabled") + } + } + }); + } + + let bef, aft; + + buttons.forEach((button) => { // Find the selected posts + let id = getRevisionId(button); + if (button.checked && id[0] === "before") { + bef = id[1]; + } + + if (button.checked && id[0] === "after") { + aft = id[1]; + } + }) + + document.getElementById("compare-submit").href = `/history/compare/${bef}/${aft}` // Switch the buttons HREF to point to the correct compare URL + +} + +buttons.forEach(function(button){ + button.checked = false; // Some browsers remember if a button is checked. + button.onchange = function() { + radioButtonChecked(button); + } +}); +}(); diff --git a/templates/wiki/compare_revision.html b/templates/wiki/compare_revision.html new file mode 100644 index 00000000..34ab61bc --- /dev/null +++ b/templates/wiki/compare_revision.html @@ -0,0 +1,13 @@ +{% extends "wiki/base.html" %} +{% block title %}Wiki | Comparing {{ title }}{% endblock %} +{% block og_title %}Wiki | Comparing {{ title }}{% endblock %} +{% block og_description %}{% endblock %} +{% block content %} + <div class="uk-container uk-container-small"> + <h2 class="uk-title"> + Revision comparison for {{ title }} + </h2> + + {{ diff | safe }} + </div> +{% endblock %} diff --git a/templates/wiki/page_edit.html b/templates/wiki/page_edit.html index b59a8912..a2d709e2 100644 --- a/templates/wiki/page_edit.html +++ b/templates/wiki/page_edit.html @@ -99,5 +99,23 @@ document.getElementById("title").oninput = function() { document.getElementById("preview-title").textContent = document.getElementById("title").value; } + + function refreshLock(){ + console.log("Refreshing lock") + let xhttp = new XMLHttpRequest(); + xhttp.onreadystatechange = function() { + if (this.readyState === 4 && this.status === 204) { + console.log("Lock refreshed") + } else if(this.readyState === 4 && this.status !== 204) { + console.log("Could not refresh lock") + } + }; + + xhttp.open("PATCH", document.location.pathname, true); + xhttp.send(); + } + + // Lock refreshing + setInterval(refreshLock, (60 * 4) * 1000) </script> -{% endblock %}
\ No newline at end of file +{% endblock %} diff --git a/templates/wiki/page_in_use.html b/templates/wiki/page_in_use.html new file mode 100644 index 00000000..4110ee17 --- /dev/null +++ b/templates/wiki/page_in_use.html @@ -0,0 +1,13 @@ +{% extends "wiki/base.html" %} +{% block title %}Wiki Error{% endblock %} +{% block og_title %}Wiki Error{% endblock %} +{% block og_description %}{% endblock %} +{% block content %} + <div class="uk-container uk-container-small"> + <div uk-alert class="uk-alert-warning"> + <a class="uk-alert-close" uk-close></a> + <h3>The page you requested is currently being edited</h3> + <p>Please try again in a little bit when the lock has expired.</p> + </div> + </div> +{% endblock %} diff --git a/templates/wiki/revision_list.html b/templates/wiki/revision_list.html new file mode 100644 index 00000000..6eb5b6a8 --- /dev/null +++ b/templates/wiki/revision_list.html @@ -0,0 +1,36 @@ +{% extends "wiki/base.html" %} +{% block title %}Wiki | Revisions to {{ page }}{% endblock %} +{% block og_title %}Wiki | Revisions to {{ page }}{% endblock %} +{% block og_description %}{% endblock %} +{% block content %} + <div class="uk-container"> + <h2>Wiki page revisions</h2> + <table class="uk-table uk-table-hover"> + <thead> + <tr> + <th>Page title</th> + <th>Date</th> + <th>User ID</th> + <th>Compare before</th> + <th>Compare after</th> + </tr> + </thead> + <tbody> + {% for revision in revisions %} + <tr> + <td>{{ revision["post"]["title"] }}</td> + <td>{{ revision["pretty_time"] }}</td> + <td>{{ revision['user'] }}</td> + <td><input type="radio" id="compare-before-{{ revision['id'] }}" class="uk-radio"></td> + <td><input type="radio" id="compare-after-{{ revision['id'] }}" class="uk-radio"></td> + </tr> + {% endfor %} + </tbody> + </table> + <a href="#" id="compare-submit" class="uk-button uk-button-primary">Compare selections</a> + </div> + <script> + let revisions = {{ revisions | tojson }} + </script> + <script src="{{ static_file("js/revision_diff.js") }}"></script> +{% endblock %} |