From a4dd4f04293b6ad6dab5d3dc0a4c52a3290b4394 Mon Sep 17 00:00:00 2001 From: rubenwardy Date: Sat, 11 Jul 2020 02:32:17 +0100 Subject: [PATCH] Add audit log --- app/blueprints/admin/__init__.py | 2 +- app/blueprints/admin/audit.py | 30 ++++++++++++++ app/blueprints/packages/packages.py | 30 +++++++++----- app/blueprints/threads/__init__.py | 24 +++++++----- app/blueprints/users/profile.py | 17 +++++++- app/models.py | 49 +++++++++++++++++++++++ app/templates/admin/audit.html | 58 ++++++++++++++++++++++++++++ app/templates/base.html | 3 ++ app/utils.py | 5 +++ migrations/versions/ba730ce1dc3e_.py | 47 ++++++++++++++++++++++ 10 files changed, 244 insertions(+), 21 deletions(-) create mode 100644 app/blueprints/admin/audit.py create mode 100644 app/templates/admin/audit.html create mode 100644 migrations/versions/ba730ce1dc3e_.py diff --git a/app/blueprints/admin/__init__.py b/app/blueprints/admin/__init__.py index 66eb1ea..03cfc8a 100644 --- a/app/blueprints/admin/__init__.py +++ b/app/blueprints/admin/__init__.py @@ -19,4 +19,4 @@ from flask import Blueprint bp = Blueprint("admin", __name__) -from . import admin, licenseseditor, tagseditor, versioneditor +from . import admin, licenseseditor, tagseditor, versioneditor, audit diff --git a/app/blueprints/admin/audit.py b/app/blueprints/admin/audit.py new file mode 100644 index 0000000..64dc3a7 --- /dev/null +++ b/app/blueprints/admin/audit.py @@ -0,0 +1,30 @@ +# ContentDB +# Copyright (C) 2020 rubenwardy +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +from flask import Blueprint, render_template, redirect, url_for +from flask_user import current_user, login_required +from app.models import db, AuditLogEntry, UserRank +from app.utils import rank_required + +from . import bp + +@bp.route("/admin/audit/") +@login_required +@rank_required(UserRank.MODERATOR) +def audit(): + log = AuditLogEntry.query.order_by(db.desc(AuditLogEntry.created_at)).all() + return render_template("admin/audit.html", log=log) diff --git a/app/blueprints/packages/packages.py b/app/blueprints/packages/packages.py index 3f26444..4743a57 100644 --- a/app/blueprints/packages/packages.py +++ b/app/blueprints/packages/packages.py @@ -265,8 +265,13 @@ def create_edit(author=None, name=None): return redirect(url_for("packages.create_edit", author=author, name=name)) else: + msg = "Edited {}".format(package.title) + addNotification(package.maintainers, current_user, - "Edited {}".format(package.title), package.getDetailsURL(), package) + msg, package.getDetailsURL(), package) + + severity = AuditSeverity.NORMAL if current_user in package.maintainers else AuditSeverity.EDITOR + addAuditLog(severity, current_user, msg, package.getDetailsURL(), package) form.populate_obj(package) # copy to row @@ -337,8 +342,10 @@ def approve(package): for s in screenshots: s.approved = True - addNotification(package.maintainers, current_user, - "Approved {}".format(package.title), package.getDetailsURL(), package) + msg = "Approved {}".format(package.title) + addNotification(package.maintainers, current_user, msg, package.getDetailsURL(), package) + severity = AuditSeverity.NORMAL if current_user == package.author else AuditSeverity.EDITOR + addAuditLog(severity, current_user, msg, package.getDetailsURL(), package) db.session.commit() return redirect(package.getDetailsURL()) @@ -359,8 +366,9 @@ def remove(package): package.soft_deleted = True url = url_for("users.profile", username=package.author.username) - addNotification(package.maintainers, current_user, - "Deleted {}".format(package.title), url, package) + msg = "Deleted {}".format(package.title) + addNotification(package.maintainers, current_user, msg, url, package) + addAuditLog(AuditSeverity.EDITOR, current_user, msg, url) db.session.commit() flash("Deleted package", "success") @@ -373,8 +381,10 @@ def remove(package): package.approved = False - addNotification(package.maintainers, current_user, - "Unapproved {}".format(package.title), package.getDetailsURL(), package) + msg = "Unapproved {}".format(package.title) + addNotification(package.maintainers, current_user, msg, package.getDetailsURL(), package) + addAuditLog(AuditSeverity.EDITOR, current_user, msg, package.getDetailsURL(), package) + db.session.commit() flash("Unapproved package", "success") @@ -420,8 +430,10 @@ def edit_maintainers(package): package.maintainers.extend(users) package.maintainers.append(package.author) - addNotification(package.author, current_user, - "Edited {} maintainers".format(package.title), package.getDetailsURL(), package) + msg = "Edited {} maintainers".format(package.title) + addNotification(package.author, current_user, msg, package.getDetailsURL(), package) + severity = AuditSeverity.NORMAL if current_user == package.author else AuditSeverity.MODERATION + addAuditLog(severity, current_user, msg, package.getDetailsURL(), package) db.session.commit() diff --git a/app/blueprints/threads/__init__.py b/app/blueprints/threads/__init__.py index e54c7c8..e3043c0 100644 --- a/app/blueprints/threads/__init__.py +++ b/app/blueprints/threads/__init__.py @@ -21,7 +21,7 @@ bp = Blueprint("threads", __name__) from flask_user import * from app.models import * -from app.utils import addNotification, clearNotifications, isYes +from app.utils import addNotification, clearNotifications, isYes, addAuditLog import datetime @@ -91,13 +91,19 @@ def set_lock(id): if thread.locked is None: abort(400) - db.session.commit() - + msg = None if thread.locked: + msg = "Locked thread '{}'".format(thread.title) flash("Locked thread", "success") else: + msg = "Unlocked thread '{}'".format(thread.title) flash("Unlocked thread", "success") + addNotification(thread.watchers, current_user, msg, thread.getViewURL(), thread.package) + addAuditLog(AuditSeverity.MODERATION, current_user, msg, thread.getViewURL(), thread.package) + + db.session.commit() + return redirect(thread.getViewURL()) @@ -129,10 +135,10 @@ def view(id): thread.watchers.append(current_user) msg = "New comment on '{}'".format(thread.title) - addNotification(thread.watchers, current_user, msg, url_for("threads.view", id=thread.id), thread.package) + addNotification(thread.watchers, current_user, msg, thread.getViewURL(), thread.package) db.session.commit() - return redirect(url_for("threads.view", id=id)) + return redirect(thread.getViewURL()) else: flash("Comment needs to be between 3 and 500 characters.") @@ -175,7 +181,7 @@ def new(): # Only allow creating one thread when not approved elif is_review_thread and package.review_thread is not None: flash("A review thread already exists!", "danger") - return redirect(url_for("threads.view", id=package.review_thread.id)) + return redirect(package.review_thread.getViewURL()) elif not current_user.canOpenThreadRL(): flash("Please wait before opening another thread", "danger") @@ -218,14 +224,14 @@ def new(): notif_msg = "New thread '{}'".format(thread.title) if package is not None: - addNotification(package.maintainers, current_user, notif_msg, url_for("threads.view", id=thread.id), package) + addNotification(package.maintainers, current_user, notif_msg, thread.getViewURL(), package) editors = User.query.filter(User.rank >= UserRank.EDITOR).all() - addNotification(editors, current_user, notif_msg, url_for("threads.view", id=thread.id), package) + addNotification(editors, current_user, notif_msg, thread.getViewURL(), package) db.session.commit() - return redirect(url_for("threads.view", id=thread.id)) + return redirect(thread.getViewURL()) return render_template("threads/new.html", form=form, allow_private_change=allow_change, package=package) diff --git a/app/blueprints/users/profile.py b/app/blueprints/users/profile.py index 47543dc..d33bc50 100644 --- a/app/blueprints/users/profile.py +++ b/app/blueprints/users/profile.py @@ -24,7 +24,7 @@ from app.models import * from flask_wtf import FlaskForm from wtforms import * from wtforms.validators import * -from app.utils import randomString, loginUser, rank_required, nonEmptyOrNone +from app.utils import randomString, loginUser, rank_required, nonEmptyOrNone, addAuditLog from app.tasks.forumtasks import checkForumAccount from app.tasks.emails import sendVerifyEmail, sendEmailRaw from app.tasks.phpbbparser import getProfile @@ -62,6 +62,10 @@ def profile(username): # Process valid POST if request.method=="POST" and form.validate(): + severity = AuditSeverity.NORMAL if current_user == user else AuditSeverity.MODERATION + addAuditLog(severity, current_user, "Edited {}'s profile".format(user.display_name), + url_for("users.profile", username=username)) + # Copy form fields to user_profile fields if user.checkPerm(current_user, Permission.CHANGE_USERNAMES): user.display_name = form.display_name.data @@ -75,7 +79,10 @@ def profile(username): if user.checkPerm(current_user, Permission.CHANGE_RANK): newRank = form["rank"].data if current_user.rank.atLeast(newRank): - user.rank = form["rank"].data + if newRank != user.rank: + user.rank = form["rank"].data + msg = "Set rank of {} to {}".format(user.display_name, user.rank.getTitle()) + addAuditLog(AuditSeverity.MODERATION, current_user, msg, url_for("users.profile", username=username)) else: flash("Can't promote a user to a rank higher than yourself!", "danger") @@ -84,6 +91,9 @@ def profile(username): if newEmail != user.email and newEmail.strip() != "": token = randomString(32) + msg = "Changed email of {}".format(user.display_name) + addAuditLog(severity, current_user, msg, url_for("users.profile", username=username)) + ver = UserEmailVerification() ver.user = user ver.token = token @@ -158,6 +168,9 @@ def send_email(username): form = SendEmailForm(request.form) if form.validate_on_submit(): + addAuditLog(AuditSeverity.MODERATION, current_user, + "Sent email to {}".format(user.display_name), url_for("users.profile", username=username)) + text = form.text.data html = render_markdown(text) task = sendEmailRaw.delay([user.email], form.subject.data, text, html) diff --git a/app/models.py b/app/models.py index d37dea3..13d1fdd 100644 --- a/app/models.py +++ b/app/models.py @@ -1164,6 +1164,55 @@ class PackageReview(db.Model): name=self.package.name) +class AuditSeverity(enum.Enum): + NORMAL = 0 # Normal user changes + EDITOR = 1 # Editor changes + MODERATION = 2 # Destructive / moderator changes + + def __str__(self): + return self.name + + def getTitle(self): + return self.name.replace("_", " ").title() + + @classmethod + def choices(cls): + return [(choice, choice.getTitle()) for choice in cls] + + @classmethod + def coerce(cls, item): + return item if type(item) == AuditSeverity else AuditSeverity[item] + + + +class AuditLogEntry(db.Model): + id = db.Column(db.Integer, primary_key=True) + + created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow) + + causer_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + causer = db.relationship("User", foreign_keys=[causer_id]) + + severity = db.Column(db.Enum(AuditSeverity), nullable=False) + + title = db.Column(db.String(100), nullable=False) + url = db.Column(db.String(200), nullable=True) + + package_id = db.Column(db.Integer, db.ForeignKey("package.id"), nullable=True) + package = db.relationship("Package", foreign_keys=[package_id]) + + def __init__(self, causer, severity, title, url, package=None): + if len(title) > 100: + title = title[:99] + "…" + + self.causer = causer + self.severity = severity + self.title = title + self.url = url + self.package = package + + + REPO_BLACKLIST = [".zip", "mediafire.com", "dropbox.com", "weebly.com", \ "minetest.net", "dropboxusercontent.com", "4shared.com", \ diff --git a/app/templates/admin/audit.html b/app/templates/admin/audit.html new file mode 100644 index 0000000..4255b72 --- /dev/null +++ b/app/templates/admin/audit.html @@ -0,0 +1,58 @@ +{% extends "base.html" %} + +{% block title %} +Audit Log +{% endblock %} + +{% block content %} +

Audit Log

+ +
+ {% for entry in log %} + +
+
+ {% if entry.severity == entry.severity.MODERATION %} + + {% elif entry.severity == entry.severity.EDITOR %} + + {% endif %} +
+ +
+ + + {{ entry.causer.display_name }} +
+ +
+ {{ entry.title}} +
+ + {% if entry.package %} +
+ + {{ entry.package.title }} + + + +
+ {% endif %} + + +
+ {{ entry.created_at | datetime }} +
+
+
+ {% else %} +

No audit log entires.

+ {% endfor %} + +{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html index 90e49a8..534538b 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -92,6 +92,9 @@ + {% if current_user.rank.atLeast(current_user.rank.MODERATOR) %} + + {% endif %} {% if current_user.rank == current_user.rank.ADMIN %} {% endif %} diff --git a/app/utils.py b/app/utils.py index 5f47f75..0f5a916 100644 --- a/app/utils.py +++ b/app/utils.py @@ -204,6 +204,11 @@ def addNotification(target, causer, title, url, package=None): db.session.add(notif) +def addAuditLog(severity, causer, title, url, package=None): + entry = AuditLogEntry(causer, severity, title, url, package) + db.session.add(entry) + + def clearNotifications(url): if current_user.is_authenticated: Notification.query.filter_by(user=current_user, url=url).delete() diff --git a/migrations/versions/ba730ce1dc3e_.py b/migrations/versions/ba730ce1dc3e_.py new file mode 100644 index 0000000..4d8da7c --- /dev/null +++ b/migrations/versions/ba730ce1dc3e_.py @@ -0,0 +1,47 @@ +"""empty message + +Revision ID: ba730ce1dc3e +Revises: 8679442b8dde +Create Date: 2020-07-11 00:59:13.519267 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'ba730ce1dc3e' +down_revision = '8679442b8dde' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('audit_log_entry', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('causer_id', sa.Integer(), nullable=False), + sa.Column('severity', sa.Enum('NORMAL', 'EDITOR', 'MODERATION', name='auditseverity'), nullable=False), + sa.Column('title', sa.String(length=100), nullable=False), + sa.Column('url', sa.String(length=200), nullable=True), + sa.Column('package_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['causer_id'], ['user.id'], ), + sa.ForeignKeyConstraint(['package_id'], ['package.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.alter_column('thread', 'private', + existing_type=sa.BOOLEAN(), + nullable=False, + existing_server_default=sa.text('false')) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('thread', 'private', + existing_type=sa.BOOLEAN(), + nullable=True, + existing_server_default=sa.text('false')) + op.drop_table('audit_log_entry') + # ### end Alembic commands ###