]> git.lizzy.rs Git - cheatdb.git/commitdiff
Add audit log
authorrubenwardy <rw@rubenwardy.com>
Sat, 11 Jul 2020 01:32:17 +0000 (02:32 +0100)
committerrubenwardy <rw@rubenwardy.com>
Sat, 11 Jul 2020 01:32:17 +0000 (02:32 +0100)
app/blueprints/admin/__init__.py
app/blueprints/admin/audit.py [new file with mode: 0644]
app/blueprints/packages/packages.py
app/blueprints/threads/__init__.py
app/blueprints/users/profile.py
app/models.py
app/templates/admin/audit.html [new file with mode: 0644]
app/templates/base.html
app/utils.py
migrations/versions/ba730ce1dc3e_.py [new file with mode: 0644]

index 66eb1eabf21876240760f1443183d171e820dcba..03cfc8a65c9ea9fa3f0ca134b57720de4b93c67a 100644 (file)
@@ -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 (file)
index 0000000..64dc3a7
--- /dev/null
@@ -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 <https://www.gnu.org/licenses/>.
+
+
+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)
index 3f26444188ea67724ddbf99b193cb4200df155cd..4743a57a1b965184310c39d4c2f2322803d14d11 100644 (file)
@@ -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()
 
index e54c7c82ff53909cd44b2df25b86163c56ee1ed3..e3043c0d68f4fff29dbcf82d55f3ee24d801924b 100644 (file)
@@ -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)
index 47543dc1fc1e776f95164892c94ddc6faba0cb25..d33bc50e40cb72b683e4035d83184f15cb2ae4bc 100644 (file)
@@ -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)
index d37dea30603e1a635c0eb77acf0c8e29fb41d3f4..13d1fdd986b9ece48be40cae96464b46b73660ff 100644 (file)
@@ -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 (file)
index 0000000..4255b72
--- /dev/null
@@ -0,0 +1,58 @@
+{% extends "base.html" %}
+
+{% block title %}
+Audit Log
+{% endblock %}
+
+{% block content %}
+       <h1>Audit Log</h1>
+
+       <div class="list-group mt-3">
+               {% for entry in log %}
+                       <a class="list-group-item list-group-item-action" href="{{ entry.url }}">
+                               <div class="row {% if entry.severity == entry.severity.NORMAL %}text-muted{% endif %}">
+                                       <div class="col-sm-auto text-center" style="width: 50px;">
+                                               {% if entry.severity == entry.severity.MODERATION %}
+                                                       <i class="fas fa-exclamation-triangle" style="color: yellow;"></i>
+                                               {% elif entry.severity == entry.severity.EDITOR %}
+                                                       <i class="fas fa-users" style="color: #537eac;"></i>
+                                               {% endif %}
+                                       </div>
+
+                                       <div class="col-sm-2 text-muted">
+                                               <img
+                                                       class="img-responsive user-photo img-thumbnail img-thumbnail-1"
+                                                       style="max-height: 22px;"
+                                                       src="{{ entry.causer.getProfilePicURL() }}" />
+
+                                               <span class="pl-2">{{ entry.causer.display_name }}</span>
+                                       </div>
+
+                                       <div class="col-sm">
+                                               {{ entry.title}}
+                                       </div>
+
+                                       {% if entry.package %}
+                                               <div class="col-sm-auto text-muted">
+                                                       <span class="pr-2">
+                                                               {{ entry.package.title }}
+                                                       </span>
+
+                                                       <img
+                                                               class="img-responsive"
+                                                               style="max-height: 22px; max-width: 22px;"
+                                                               src="{{ entry.package.getThumbnailURL(1) }}" />
+                                               </div>
+                                       {% endif %}
+
+
+                                       <div class="col-sm-auto text-muted">
+                                               {{ entry.created_at | datetime }}
+                                       </div>
+                               </div>
+                       </a>
+               {% else %}
+                       <p class="list-group-item"><i>No audit log entires.</i></p>
+               {% endfor %}
+       </ul>
+{% endblock %}
index 90e49a87778e766a6236154937ff0f4d37652b54..534538b5c6d2d312682699d6f1c91b82060eeb06 100644 (file)
@@ -92,6 +92,9 @@
                                                                <li class="nav-item">
                                                                        <a class="nav-link" href="{{ url_for('todo.topics') }}">{{ _("All unadded topics") }}</a>
                                                                </li>
+                                                               {% if current_user.rank.atLeast(current_user.rank.MODERATOR) %}
+                                                                       <li class="nav-item"><a class="nav-link" href="{{ url_for('admin.audit') }}">{{ _("Audit Log") }}</a></li>
+                                                               {% endif %}
                                                                {% if current_user.rank == current_user.rank.ADMIN %}
                                                                        <li class="nav-item"><a class="nav-link" href="{{ url_for('admin.admin_page') }}">{{ _("Admin") }}</a></li>
                                                                {% endif %}
index 5f47f755c8b10402c4e0a25d226644fd58e60b84..0f5a91672e09e5a1eb015846f8b72a669148ba6a 100644 (file)
@@ -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 (file)
index 0000000..4d8da7c
--- /dev/null
@@ -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 ###