aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Joseph <[email protected]>2018-04-17 23:12:35 +0100
committerGravatar GitHub <[email protected]>2018-04-17 23:12:35 +0100
commitc99f816e71d28dfaf3bd44d27d211f2b44778f48 (patch)
tree297747765dfcd5163839333c0879e04e2e4b565b
parentJSON file had the wrong name. I should be incarcerated. (diff)
Add wiki audit logs (#52)
* Add wiki audit logs * Flake8 issues * Forgot to remove debug prints * Removed print but forgot to remove `.text` * Futile attempt to increase coverage * Reading up on API docs asks us to send User-Agent with our info in it * Add tests for wiki * Well played, Travis * Nothing in my local env is working on travis lol * Why does it appear it can't decide between 200 & 302 :thinking: * Flake8 * Post route * Same issue with local env working differently from Travis env * Add page to show page is being edited * Lock page when it is being edited * Add JS to keep refreshing lock on article * Add compare route to render diff * Add revision table to database * Add revision storage * Add revision list route * JS for Revision list radio buttons * Add revision comparison template * Add revision list template * flak8 my ass * [Unit Tests] New unit tests for history routes :sparkles: * Does this fix things? * Address gdude's reviews * Noticed a bug in the diff generation route which meant that empty posts could potentially error * Flake 8 * Audit logs were logging after change, meaning it was comparing after & after, which didn't work * Change table name * Remove whitespace from line to fix flake8 issue * Address reviews * Git hook made me do something wrong, apologies * Switch from werkzeug redirect to flask one * Address Apertures reviews
-rw-r--r--app_test.py21
-rw-r--r--pysite/constants.py6
-rw-r--r--pysite/database.py3
-rw-r--r--pysite/views/wiki/edit.py133
-rw-r--r--pysite/views/wiki/history/compare.py51
-rw-r--r--pysite/views/wiki/history/show.py27
-rw-r--r--static/js/revision_diff.js75
-rw-r--r--templates/wiki/compare_revision.html13
-rw-r--r--templates/wiki/page_edit.html20
-rw-r--r--templates/wiki/page_in_use.html13
-rw-r--r--templates/wiki/revision_list.html36
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 %}