mirror of
https://github.com/minetest/contentdb.git
synced 2025-01-03 11:47:28 +01:00
Add audit log
This commit is contained in:
parent
bf927c50f0
commit
a4dd4f0429
@ -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
|
||||
|
30
app/blueprints/admin/audit.py
Normal file
30
app/blueprints/admin/audit.py
Normal file
@ -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)
|
@ -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()
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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):
|
||||
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)
|
||||
|
@ -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", \
|
||||
|
58
app/templates/admin/audit.html
Normal file
58
app/templates/admin/audit.html
Normal file
@ -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 %}
|
@ -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 %}
|
||||
|
@ -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()
|
||||
|
47
migrations/versions/ba730ce1dc3e_.py
Normal file
47
migrations/versions/ba730ce1dc3e_.py
Normal file
@ -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 ###
|
Loading…
Reference in New Issue
Block a user