This commit is contained in:
rimu 2024-07-07 15:01:52 +08:00
parent 4073f119f6
commit 3647e2796d
11 changed files with 234 additions and 22 deletions

View file

@ -1509,7 +1509,7 @@ def post_ap2(post_id):
@bp.route('/post/<int:post_id>', methods=['GET', 'POST'])
@cache.cached(timeout=5, make_cache_key=make_cache_key)
@cache.cached(timeout=3, make_cache_key=make_cache_key)
def post_ap(post_id):
if request.method == 'GET' and is_activitypub_request():
post = Post.query.get_or_404(post_id)

View file

@ -32,7 +32,7 @@ from app.utils import get_request, allowlist_html, get_setting, ap_datetime, mar
shorten_string, reply_already_exists, reply_is_just_link_to_gif_reaction, confidence, remove_tracking_from_link, \
blocked_phrases, microblog_content_to_title, generate_image_from_video_url, is_video_url, reply_is_stupid, \
notification_subscribers, communities_banned_from, lemmy_markdown_to_html, actor_contains_blocked_words, \
html_to_text, opengraph_parse, url_to_thumbnail_file
html_to_text, opengraph_parse, url_to_thumbnail_file, add_to_modlog_activitypub
def public_key():
@ -1434,6 +1434,9 @@ def delete_post_or_comment_task(user_ap_id, community_ap_id, to_be_deleted_ap_id
to_delete.deleted = True
community.post_count -= 1
db.session.commit()
if to_delete.author.id != deletor.id:
add_to_modlog_activitypub('delete_post', deletor, community_id=community.id,
link_text=shorten_string(to_delete.title), link=f'post/{to_delete.id}')
elif isinstance(to_delete, PostReply):
if not to_delete.author.bot:
to_delete.post.reply_count -= 1
@ -1443,8 +1446,11 @@ def delete_post_or_comment_task(user_ap_id, community_ap_id, to_be_deleted_ap_id
else:
to_delete.delete_dependencies()
to_delete.deleted = True
db.session.commit()
if to_delete.author.id != deletor.id:
add_to_modlog_activitypub('delete_post_reply', deletor, community_id=community.id,
link_text=f'comment on {shorten_string(to_delete.post.title)}',
link=f'post/{to_delete.post.id}#comment_{to_delete.id}')
def remove_data_from_banned_user(deletor_ap_id, user_ap_id, target):

View file

@ -25,7 +25,7 @@ from app.models import AllowedInstances, BannedInstances, ActivityPubLog, utcnow
User, Instance, File, Report, Topic, UserRegistration, Role, Post, PostReply, Language
from app.utils import render_template, permission_required, set_setting, get_setting, gibberish, markdown_to_html, \
moderating_communities, joined_communities, finalize_user_setup, theme_list, blocked_phrases, blocked_referrers, \
topic_tree, languages_for_form, menu_topics, ensure_directory_exists
topic_tree, languages_for_form, menu_topics, ensure_directory_exists, add_to_modlog
from app.admin import bp
@ -869,6 +869,8 @@ def admin_user_delete(user_id):
user.delete_dependencies()
db.session.commit()
add_to_modlog('delete_user', link_text=user.display_name(), link=user.link())
flash(_('User deleted'))
return redirect(url_for('admin.admin_users'))

View file

@ -25,7 +25,7 @@ from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, POST_TYPE_LIN
from app.inoculation import inoculation
from app.models import User, Community, CommunityMember, CommunityJoinRequest, CommunityBan, Post, \
File, PostVote, utcnow, Report, Notification, InstanceBlock, ActivityPubLog, Topic, Conversation, PostReply, \
NotificationSubscription, UserFollower, Instance, Language, Poll, PollChoice
NotificationSubscription, UserFollower, Instance, Language, Poll, PollChoice, ModLog
from app.community import bp
from app.user.utils import search_for_user
from app.utils import get_setting, render_template, allowlist_html, markdown_to_html, validation_required, \
@ -33,7 +33,7 @@ from app.utils import get_setting, render_template, allowlist_html, markdown_to_
request_etag_matches, return_304, instance_banned, can_create_post, can_upvote, can_downvote, user_filters_posts, \
joined_communities, moderating_communities, blocked_domains, mimetype_from_url, blocked_instances, \
community_moderators, communities_banned_from, show_ban_message, recently_upvoted_posts, recently_downvoted_posts, \
blocked_users, post_ranking, languages_for_form, english_language_id, menu_topics
blocked_users, post_ranking, languages_for_form, english_language_id, menu_topics, add_to_modlog
from feedgen.feed import FeedGenerator
from datetime import timezone, timedelta
from copy import copy
@ -1218,6 +1218,10 @@ def community_delete(community_id: int):
community.delete_dependencies()
db.session.delete(community)
db.session.commit()
add_to_modlog('delete_community', community_id=community.id, link_text=community.display_name(),
link=community.link())
flash(_('Community deleted'))
return redirect('/communities')
@ -1287,7 +1291,11 @@ def community_add_moderator(community_id: int):
db.session.add(existing_conversation)
db.session.commit()
server = current_app.config['SERVER_NAME']
send_message(f"Hi there. I've added you as a moderator to the community !{community.name}@{server}.", existing_conversation.id)
send_message(f"Hi there. I've added you as a moderator to the community !{community.name}@{server}.",
existing_conversation.id)
add_to_modlog('add_mod', community_id=community_id, link_text=new_moderator.display_name(),
link=new_moderator.link())
# Flush cache
cache.delete_memoized(moderating_communities, new_moderator.id)
@ -1319,6 +1327,12 @@ def community_remove_moderator(community_id: int, user_id: int):
existing_member.is_moderator = False
db.session.commit()
flash(_('Moderator removed'))
removed_mod = User.query.get(existing_member.user_id)
add_to_modlog('remove_mod', community_id=community_id, link_text=removed_mod.display_name(),
link=removed_mod.link())
# Flush cache
cache.delete_memoized(moderating_communities, user_id)
cache.delete_memoized(joined_communities, user_id)
@ -1401,6 +1415,8 @@ def community_ban_user(community_id: int, user_id: int):
NotificationSubscription.user_id == user.id,
NotificationSubscription.type == NOTIF_COMMUNITY).delete()
add_to_modlog('ban_user', community_id=community.id, link_text=user.display_name(), link=user.link())
return redirect(community.local_url())
else:
return render_template('community/community_ban_user.html', title=_('Ban from community'), form=form, community=community,
@ -1446,6 +1462,8 @@ def community_unban_user(community_id: int, user_id: int):
...
# todo: send chatmessage to remote user and federate it
add_to_modlog('unban_user', community_id=community.id, link_text=user.display_name(), link=user.link())
return redirect(url_for('community.community_moderate_subscribers', actor=community.link()))
@ -1556,6 +1574,42 @@ def community_moderate_subscribers(actor):
abort(404)
@bp.route('/<actor>/moderate/modlog', methods=['GET'])
@login_required
def community_modlog(actor):
community = actor_to_community(actor)
if community is not None:
if community.is_moderator() or current_user.is_admin():
page = request.args.get('page', 1, type=int)
low_bandwidth = request.cookies.get('low_bandwidth', '0') == '1'
modlog_entries = ModLog.query.filter(ModLog.community_id == community.id).order_by(desc(ModLog.created_at))
# Pagination
modlog_entries = modlog_entries.paginate(page=page, per_page=100 if not low_bandwidth else 50, error_out=False)
next_url = url_for('community.community_modlog', actor=actor,
page=modlog_entries.next_num) if modlog_entries.has_next else None
prev_url = url_for('community.community_modlog', actor=actor,
page=modlog_entries.prev_num) if modlog_entries.has_prev and page != 1 else None
return render_template('community/community_modlog.html',
title=_('Mod Log of %(community)s', community=community.display_name()),
community=community, current='modlog', modlog_entries=modlog_entries,
next_url=next_url, prev_url=prev_url, low_bandwidth=low_bandwidth,
moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.get_id()),
menu_topics=menu_topics(), site=g.site,
inoculation=inoculation[randint(0, len(inoculation) - 1)]
)
else:
abort(401)
else:
abort(404)
@bp.route('/community/<int:community_id>/moderate_report/<int:report_id>/escalate', methods=['GET', 'POST'])
@login_required
def community_moderate_report_escalate(community_id, report_id):

View file

@ -741,6 +741,22 @@ class User(UserMixin, db.Model):
return True
return False
@cache.memoize(timeout=30)
def is_staff(self):
for role in self.roles:
if role.name == 'Staff':
return True
return False
def is_instance_admin(self):
if self.instance_id:
instance_role = InstanceRole.query.filter(InstanceRole.instance_id == self.instance_id,
InstanceRole.user_id == self.id,
InstanceRole.role == 'admin').first()
return instance_role is not None
else:
return False
def trustworthy(self):
if self.is_admin():
return True
@ -1477,6 +1493,31 @@ class ModLog(db.Model):
public = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=utcnow)
community = db.relationship('Community', lazy='joined', foreign_keys=[community_id])
author = db.relationship('User', lazy='joined', foreign_keys=[user_id])
action_map = {
'add_mod': _l('Added moderator'),
'remove_mod': _l('Removed moderator'),
'featured_post': _l('Featured post'),
'unfeatured_post': _l('Unfeatured post'),
'delete_post': _l('Deleted post'),
'restore_post': _l('Un-deleted post'),
'delete_post_reply': _l('Deleted comment'),
'restore_post_reply': _l('Un-deleted comment'),
'delete_community': _l('Deleted community'),
'delete_user': _l('Deleted account'),
'undelete_user': _l('Restored account'),
'ban_user': _l('Banned account'),
'unban_user': _l('Un-banned account'),
}
def action_to_str(self):
if self.action in self.action_map:
return self.action_map[self.action]
else:
return self.action
class IpBan(db.Model):
id = db.Column(db.Integer, primary_key=True)

View file

@ -3,7 +3,7 @@ from datetime import datetime, timedelta
from random import randint
from flask import redirect, url_for, flash, current_app, abort, request, g, make_response
from flask_login import login_user, logout_user, current_user, login_required
from flask_login import logout_user, current_user, login_required
from flask_babel import _
from sqlalchemy import or_, desc
from wtforms import SelectField, RadioField
@ -31,7 +31,7 @@ from app.utils import get_setting, render_template, allowlist_html, markdown_to_
reply_already_exists, reply_is_just_link_to_gif_reaction, confidence, moderating_communities, joined_communities, \
blocked_instances, blocked_domains, community_moderators, blocked_phrases, show_ban_message, recently_upvoted_posts, \
recently_downvoted_posts, recently_upvoted_post_replies, recently_downvoted_post_replies, reply_is_stupid, \
languages_for_form, english_language_id, MultiCheckboxField, menu_topics
languages_for_form, menu_topics, add_to_modlog
def show_post(post_id: int):
@ -39,7 +39,10 @@ def show_post(post_id: int):
community: Community = post.community
if community.banned or post.deleted:
abort(404)
if current_user.is_anonymous or not (current_user.is_authenticated and (current_user.is_admin() or current_user.is_staff())):
abort(404)
else:
flash(_('This post has been deleted and is only visible to staff and admins.'), 'warning')
sort = request.args.get('sort', 'hot')
@ -1548,6 +1551,10 @@ def post_delete(post_id: int):
for i in instances:
post_request(i.inbox, delete_json, current_user.private_key, current_user.public_url() + '#main-key')
if post.user_id != current_user.id:
add_to_modlog('delete_post', community_id=community.id, link_text=shorten_string(post.title),
link=f'post/{post.id}')
return redirect(url_for('activitypub.community_profile', actor=community.ap_id if community.ap_id is not None else community.name))
@ -1602,6 +1609,10 @@ def post_restore(post_id: int):
if instance.inbox and not current_user.has_blocked_instance(instance.id) and not instance_banned(instance.domain):
send_to_remote_instance(instance.id, post.community.id, announce)
if post.user_id != current_user.id:
add_to_modlog('restore_post', community_id=post.community.id, link_text=shorten_string(post.title),
link=f'post/{post.id}')
flash(_('Post has been restored.'))
return redirect(url_for('activitypub.post_ap', post_id=post.id))
@ -2098,6 +2109,10 @@ def post_reply_delete(post_id: int, comment_id: int):
if instance.inbox and not current_user.has_blocked_instance(instance.id) and not instance_banned(instance.domain):
send_to_remote_instance(instance.id, post.community.id, announce)
if post_reply.user_id != current_user.id:
add_to_modlog('delete_post_reply', community_id=post.community.id, link_text=f'comment on {shorten_string(post.title)}',
link=f'post/{post.id}#comment_{post_reply.id}')
return redirect(url_for('activitypub.post_ap', post_id=post.id))

View file

@ -16,7 +16,7 @@
<a href="/community/{{ community.link() }}/moderate/appeals" class="btn {{ 'btn-primary' if current == 'appeals' else 'btn-outline-secondary' }} disabled" rel="nofollow noindex" >
{{ _('Appeals') }}
</a>
<a href="/community/{{ community.link() }}/moderate/modlog" class="btn {{ 'btn-primary' if current == 'modlog' else 'btn-outline-secondary' }} disabled" rel="nofollow noindex" >
<a href="/community/{{ community.link() }}/moderate/modlog" class="btn {{ 'btn-primary' if current == 'modlog' else 'btn-outline-secondary' }} " rel="nofollow noindex" >
{{ _('Mod log') }}
</a>
</div>

View file

@ -24,7 +24,7 @@
<!-- <a class="btn btn-primary" href="{{ url_for('community.community_add_moderator', community_id=community.id) }}">{{ _('Add moderator') }}</a> -->
</div>
</div>
<p>{{ _('See and handle all reports made about %(community)s', community=community.display_name()) }}</p>
<p>{{ _('See and handle all reports made about %(community)s', community=community.display_name()) }}.</p>
{% if reports.items %}
<form method="get">
<input type="search" name="search" value="{{ search }}">
@ -34,12 +34,12 @@
</form>
<table class="table table-striped">
<tr>
<th>Local/Remote</th>
<th>Reasons</th>
<th>Description</th>
<th>Type</th>
<th>Created</th>
<th>Actions</th>
<th>{{ _('Local/Remote') }}</th>
<th>{{ _('Reasons') }}</th>
<th>{{ _('Description') }}</th>
<th>{{ _('Type') }}</th>
<th>{{ _('Created') }}</th>
<th>{{ _('Actions') }}</th>
</tr>
{% for report in reports.items %}
<tr>
@ -87,7 +87,7 @@
{% endif %}
</nav>
{% else %}
<p>{{ _('No reports yet') }}</p>
<p>{{ _('No reports yet') }}.</p>
{% endif %}
</div>
</div>

View file

@ -0,0 +1,59 @@
{% if theme() and file_exists('app/templates/themes/' + theme() + '/base.html') %}
{% extends 'themes/' + theme() + '/base.html' %}
{% else %}
{% extends "base.html" %}
{% endif %} %}
{% from 'bootstrap/form.html' import render_field %}
{% block app_content %}
<div class="row">
<div class="col-12 col-md-8 position-relative main_pane">
<nav aria-label="breadcrumb" id="breadcrumb_nav" title="Navigation">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/">{{ _('Home') }}</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('activitypub.community_profile', actor=community.ap_id if community.ap_id is not none else community.name) }}">{{ (community.title + '@' + community.ap_domain)|shorten }}</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('community.community_edit', community_id=community.id) }}">{{ _('Settings') }}</a></li>
<li class="breadcrumb-item active">{{ _('Modlog') }}</li>
</ol>
</nav>
{% include "community/_community_moderation_nav.html" %}
<div class="row">
<div class="col-12 col-md-10">
<h1 class="mt-2">{{ _('Moderation actions in %(community)s', community=community.display_name()) }}</h1>
</div>
<p>{{ _('See things moderators have done in this community.') }}</p>
</div>
{% if modlog_entries.items %}
<table class="table table-responsive">
<thead>
<tr>
<th>{{ _('When') }}</th>
<th>{{ _('Moderator') }}</th>
<th>{{ _('Action') }}</th>
</tr>
</thead>
<tbody>
{% for modlog_entry in modlog_entries.items %}
<tr>
<td>{{ moment(modlog_entry.created_at).fromNow() }}</td>
<td>{{ render_username(modlog_entry.author) }}</td>
<td>{{ modlog_entry.action_to_str() }}
{% if modlog_entry.link and modlog_entry.link_text -%}
<a href="/{{ modlog_entry.link }}">{{ modlog_entry.link_text}}</a>
{% elif modlog_entry.link_text -%}
{{ modlog_entry.link_text }}
{% endif -%}
{% if modlog_entry.reason -%}
<br>{{ _('Reason:') }} {{ modlog_entry.reason }}
{% endif -%}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else -%}
<p>{{ _('This community has had no moderation actions yet.') }}</p>
{% endif -%}
</div>
</div>
{% endblock %}

View file

@ -23,7 +23,7 @@ from app.user.utils import purge_user_then_delete, unsubscribe_from_community
from app.utils import get_setting, render_template, markdown_to_html, user_access, markdown_to_text, shorten_string, \
is_image_url, ensure_directory_exists, gibberish, file_get_contents, community_membership, user_filters_home, \
user_filters_posts, user_filters_replies, moderating_communities, joined_communities, theme_list, blocked_instances, \
allowlist_html, recently_upvoted_posts, recently_downvoted_posts, blocked_users, menu_topics
allowlist_html, recently_upvoted_posts, recently_downvoted_posts, blocked_users, menu_topics, add_to_modlog
from sqlalchemy import desc, or_, text
import os
@ -317,6 +317,8 @@ def ban_profile(actor):
user.banned = True
db.session.commit()
add_to_modlog('ban_user', link_text=user.display_name(), link=user.link())
flash(f'{actor} has been banned.')
else:
abort(401)
@ -342,6 +344,8 @@ def unban_profile(actor):
user.banned = False
db.session.commit()
add_to_modlog('unban_user', link_text=user.display_name(), link=user.link())
flash(f'{actor} has been unbanned.')
else:
abort(401)
@ -467,7 +471,7 @@ def report_profile(actor):
def delete_profile(actor):
if user_access('manage users', current_user.id):
actor = actor.strip()
user = User.query.filter_by(user_name=actor, deleted=False).first()
user:User = User.query.filter_by(user_name=actor, deleted=False).first()
if user is None:
user = User.query.filter_by(ap_id=actor, deleted=False).first()
if user is None:
@ -480,6 +484,8 @@ def delete_profile(actor):
user.delete_dependencies()
db.session.commit()
add_to_modlog('delete_user', link_text=user.display_name(), link=user.link())
flash(f'{actor} has been deleted.')
else:
abort(401)
@ -605,6 +611,9 @@ def ban_purge_profile(actor):
user.purge_content()
db.session.commit()
flash(f'{actor} has been banned, deleted and all their content deleted.')
add_to_modlog('delete_user', link_text=user.display_name(), link=user.link())
else:
abort(401)

View file

@ -36,7 +36,7 @@ from PIL import Image, ImageOps
from app.email import send_welcome_email
from app.models import Settings, Domain, Instance, BannedInstances, User, Community, DomainBlock, ActivityPubLog, IpBan, \
Site, Post, PostReply, utcnow, Filter, CommunityMember, InstanceBlock, CommunityBan, Topic, UserBlock, Language, \
File
File, ModLog
# Flask's render_template function, with support for themes added
@ -1150,3 +1150,29 @@ def actor_contains_blocked_words(actor):
if blocked_word in actor:
return True
return False
def add_to_modlog(action: str, community_id: int = None, reason: str = '', link: str = '', link_text: str = '', public: bool = False):
""" Adds a new entry to the Moderation Log """
if action not in ModLog.action_map.keys():
raise Exception('Invalid action: ' + action)
if current_user.is_admin() or current_user.is_staff():
action_type = 'admin'
else:
action_type = 'mod'
db.session.add(ModLog(user_id=current_user.id, community_id=community_id, type=action_type, action=action,
reason=reason, link=link, link_text=link_text, public=public))
db.session.commit()
def add_to_modlog_activitypub(action: str, actor: User, community_id: int = None, reason: str = '', link: str = '', link_text: str = '', public: bool = False):
""" Adds a new entry to the Moderation Log - identical to above except has an 'actor' parameter """
if action not in ModLog.action_map.keys():
raise Exception('Invalid action: ' + action)
if actor.is_instance_admin():
action_type = 'admin'
else:
action_type = 'mod'
db.session.add(ModLog(user_id=actor.id, community_id=community_id, type=action_type, action=action,
reason=reason, link=link, link_text=link_text, public=public))
db.session.commit()