bp = Blueprint("admin", __name__)
-from . import admin, licenseseditor, tagseditor, versioneditor
+from . import admin, licenseseditor, tagseditor, versioneditor, audit
--- /dev/null
+# 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)
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
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())
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")
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")
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()
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
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())
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.")
# 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")
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)
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
# 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
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")
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
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)
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", \
--- /dev/null
+{% 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 %}
<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 %}
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()
--- /dev/null
+"""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 ###