admin area to respond to moderation reports

This commit is contained in:
rimu 2024-01-02 16:07:41 +13:00
parent b4dcdf98e7
commit 520db4a924
17 changed files with 278 additions and 101 deletions

View file

@ -629,8 +629,8 @@ to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
PieFed, a federated forum
Copyright (C) 2024 Rimu Atkinson
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published

View file

@ -893,10 +893,15 @@ def create_post_reply(activity_log: ActivityPubLog, community: Community, in_rep
community.last_active = post.last_active = utcnow()
activity_log.result = 'success'
db.session.commit()
vote = PostReplyVote(user_id=user.id, author_id=post_reply.user_id,
if user.reputation > 100:
vote = PostReplyVote(user_id=1, author_id=post_reply.user_id,
post_reply_id=post_reply.id,
effect=instance_weight(user.ap_domain))
db.session.add(vote)
post_reply.up_votes += 1
post_reply.score += 1
post_reply.ranking += 1
db.session.commit()
else:
activity_log.exception_message = 'Comments disabled, reply discarded'
activity_log.result = 'ignored'
@ -920,7 +925,8 @@ def create_post(activity_log: ActivityPubLog, community: Community, request_json
ap_announce_id=announce_id,
type=constants.POST_TYPE_ARTICLE,
up_votes=1,
score=instance_weight(user.ap_domain)
score=instance_weight(user.ap_domain),
instance_id=user.instance_id
)
if 'source' in request_json['object'] and request_json['object']['source']['mediaType'] == 'text/markdown':
post.body = request_json['object']['source']['content']
@ -972,11 +978,15 @@ def create_post(activity_log: ActivityPubLog, community: Community, request_json
if post.image_id:
make_image_sizes(post.image_id, 266, None, 'posts')
vote = PostVote(user_id=user.id, author_id=post.user_id,
if user.reputation > 100:
vote = PostVote(user_id=1, author_id=post.user_id,
post_id=post.id,
effect=instance_weight(user.ap_domain))
db.session.add(vote)
post.up_votes += 1
post.score += 1
post.ranking += 1
db.session.commit()
return post

View file

@ -11,9 +11,10 @@ from app.activitypub.routes import process_inbox_request, process_delete_request
from app.activitypub.signature import post_request
from app.activitypub.util import default_context
from app.admin.forms import FederationForm, SiteMiscForm, SiteProfileForm, EditCommunityForm, EditUserForm
from app.admin.util import unsubscribe_from_everything_then_delete, unsubscribe_from_community
from app.community.util import save_icon_file, save_banner_file
from app.models import AllowedInstances, BannedInstances, ActivityPubLog, utcnow, Site, Community, CommunityMember, \
User, Instance, File
User, Instance, File, Report
from app.utils import render_template, permission_required, set_setting, get_setting, gibberish, markdown_to_html
from app.admin import bp
@ -277,13 +278,21 @@ def unsubscribe_everyone_then_delete_task(community_id):
def admin_users():
page = request.args.get('page', 1, type=int)
search = request.args.get('search', '')
local_remote = request.args.get('local_remote', '')
users = User.query.filter_by(deleted=False).order_by(User.user_name).paginate(page=page, per_page=1000, error_out=False)
users = User.query.filter_by(deleted=False)
if local_remote == 'local':
users = users.filter_by(ap_id=None)
if local_remote == 'remote':
users = users.filter(User.ap_id != None)
users = users.order_by(User.user_name).paginate(page=page, per_page=1000, error_out=False)
next_url = url_for('admin.admin_users', page=users.next_num) if users.has_next else None
prev_url = url_for('admin.admin_users', page=users.prev_num) if users.has_prev and page != 1 else None
return render_template('admin/users.html', title=_('Users'), next_url=next_url, prev_url=prev_url, users=users)
return render_template('admin/users.html', title=_('Users'), next_url=next_url, prev_url=prev_url, users=users,
local_remote=local_remote, search=search)
@bp.route('/user/<int:user_id>/edit', methods=['GET', 'POST'])
@ -370,68 +379,24 @@ def admin_user_delete(user_id):
return redirect(url_for('admin.admin_users'))
def unsubscribe_from_everything_then_delete(user_id):
if current_app.debug:
unsubscribe_from_everything_then_delete_task(user_id)
else:
unsubscribe_from_everything_then_delete_task.delay(user_id)
@bp.route('/reports', methods=['GET'])
@login_required
@permission_required('administer all users')
def admin_reports():
page = request.args.get('page', 1, type=int)
search = request.args.get('search', '')
local_remote = request.args.get('local_remote', '')
@celery.task
def unsubscribe_from_everything_then_delete_task(user_id):
user = User.query.get(user_id)
if user:
reports = Report.query.filter_by(status=0)
if local_remote == 'local':
reports = reports.filter_by(ap_id=None)
if local_remote == 'remote':
reports = reports.filter(Report.ap_id != None)
reports = reports.order_by(desc(Report.created_at)).paginate(page=page, per_page=1000, error_out=False)
# unsubscribe
communities = CommunityMember.query.filter_by(user_id=user_id).all()
for membership in communities:
community = Community.query.get(membership.community_id)
unsubscribe_from_community(community, user)
next_url = url_for('admin.admin_reports', page=reports.next_num) if reports.has_next else None
prev_url = url_for('admin.admin_reports', page=reports.prev_num) if reports.has_prev and page != 1 else None
# federate deletion of account
if user.is_local():
instances = Instance.query.all()
site = Site.query.get(1)
payload = {
"@context": default_context(),
"actor": user.ap_profile_id,
"id": f"{user.ap_profile_id}#delete",
"object": user.ap_profile_id,
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"type": "Delete"
}
for instance in instances:
if instance.inbox and instance.id != 1:
post_request(instance.inbox, payload, site.private_key,
f"https://{current_app.config['SERVER_NAME']}#main-key")
user.deleted = True
user.delete_dependencies()
db.session.commit()
def unsubscribe_from_community(community, user):
undo_id = f"https://{current_app.config['SERVER_NAME']}/activities/undo/" + gibberish(15)
follow = {
"actor": f"https://{current_app.config['SERVER_NAME']}/u/{user.user_name}",
"to": [community.ap_profile_id],
"object": community.ap_profile_id,
"type": "Follow",
"id": f"https://{current_app.config['SERVER_NAME']}/activities/follow/{gibberish(15)}"
}
undo = {
'actor': user.profile_id(),
'to': [community.ap_profile_id],
'type': 'Undo',
'id': undo_id,
'object': follow
}
activity = ActivityPubLog(direction='out', activity_id=undo_id, activity_type='Undo',
activity_json=json.dumps(undo), result='processing')
db.session.add(activity)
db.session.commit()
post_request(community.ap_inbox_url, undo, user.private_key, user.profile_id() + '#main-key')
activity.result = 'success'
db.session.commit()
return render_template('admin/reports.html', title=_('Reports'), next_url=next_url, prev_url=prev_url, reports=reports,
local_remote=local_remote, search=search)

72
app/admin/util.py Normal file
View file

@ -0,0 +1,72 @@
from flask import request, abort, g, current_app, json
from app import db, cache, celery
from app.activitypub.signature import post_request
from app.activitypub.util import default_context
from app.models import User, Community, Instance, Site, ActivityPubLog, CommunityMember
from app.utils import gibberish
def unsubscribe_from_everything_then_delete(user_id):
if current_app.debug:
unsubscribe_from_everything_then_delete_task(user_id)
else:
unsubscribe_from_everything_then_delete_task.delay(user_id)
@celery.task
def unsubscribe_from_everything_then_delete_task(user_id):
user = User.query.get(user_id)
if user:
# unsubscribe
communities = CommunityMember.query.filter_by(user_id=user_id).all()
for membership in communities:
community = Community.query.get(membership.community_id)
unsubscribe_from_community(community, user)
# federate deletion of account
if user.is_local():
instances = Instance.query.all()
site = Site.query.get(1)
payload = {
"@context": default_context(),
"actor": user.ap_profile_id,
"id": f"{user.ap_profile_id}#delete",
"object": user.ap_profile_id,
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"type": "Delete"
}
for instance in instances:
if instance.inbox and instance.id != 1:
post_request(instance.inbox, payload, site.private_key,
f"https://{current_app.config['SERVER_NAME']}#main-key")
user.deleted = True
user.delete_dependencies()
db.session.commit()
def unsubscribe_from_community(community, user):
undo_id = f"https://{current_app.config['SERVER_NAME']}/activities/undo/" + gibberish(15)
follow = {
"actor": f"https://{current_app.config['SERVER_NAME']}/u/{user.user_name}",
"to": [community.ap_profile_id],
"object": community.ap_profile_id,
"type": "Follow",
"id": f"https://{current_app.config['SERVER_NAME']}/activities/follow/{gibberish(15)}"
}
undo = {
'actor': user.profile_id(),
'to': [community.ap_profile_id],
'type': 'Undo',
'id': undo_id,
'object': follow
}
activity = ActivityPubLog(direction='out', activity_id=undo_id, activity_type='Undo',
activity_json=json.dumps(undo), result='processing')
db.session.add(activity)
db.session.commit()
post_request(community.ap_inbox_url, undo, user.private_key, user.profile_id() + '#main-key')
activity.result = 'success'
db.session.commit()

View file

@ -17,7 +17,7 @@ from app.models import User, Community, CommunityMember, CommunityJoinRequest, C
from app.community import bp
from app.utils import get_setting, render_template, allowlist_html, markdown_to_html, validation_required, \
shorten_string, markdown_to_text, domain_from_url, validate_image, gibberish, community_membership, ap_datetime, \
request_etag_matches, return_304
request_etag_matches, return_304, instance_banned
from feedgen.feed import FeedGenerator
from datetime import timezone
@ -360,6 +360,10 @@ def add_post(actor):
"object": page,
'@context': default_context()
}
if post.type == POST_TYPE_LINK:
page.attachment = [{'href': post.url, 'type': 'Link'}]
if post.image_id:
page.image = [{'type': 'Image', 'url': post.image.source_url}]
if not community.is_local(): # this is a remote community - send the post to the instance that hosts it
success = post_request(community.ap_inbox_url, create, current_user.private_key,
current_user.ap_profile_id + '#main-key')
@ -384,7 +388,7 @@ def add_post(actor):
sent_to = 0
for instance in community.following_instances():
if instance[1] and not current_user.has_blocked_instance(instance[0]):
if instance[1] and not current_user.has_blocked_instance(instance[0]) and not instance_banned(instance[1]):
send_to_remote_instance(instance[1], community.id, announce)
sent_to += 1
if sent_to:

View file

@ -255,9 +255,16 @@ def save_post(form, post: Post):
else:
raise Exception('invalid post type')
if post.id is None:
postvote = PostVote(user_id=current_user.id, author_id=current_user.id, post=post, effect=1.0)
if current_user.reputation > 100:
postvote = PostVote(user_id=1, author_id=current_user.id, post=post, effect=1.0)
post.up_votes = 1
post.score = 1
post.ranking = 1
db.session.add(postvote)
if current_user.reputation < -100:
postvote = PostVote(user_id=1, author_id=current_user.id, post=post, effect=-1.0)
post.score = -1
post.ranking = -1
db.session.add(postvote)
db.session.add(post)
g.site.last_active = utcnow()

View file

@ -175,6 +175,7 @@ class Community(db.Model):
else:
return self.ap_id
@cache.memoize(timeout=30)
def moderators(self):
return CommunityMember.query.filter((CommunityMember.community_id == self.id) &
(or_(
@ -210,7 +211,7 @@ class Community(db.Model):
# returns a list of tuples (instance.id, instance.inbox)
def following_instances(self):
sql = 'select distinct i.id, i.inbox from "instance" as i inner join "user" as u on u.instance_id = i.id inner join "community_member" as cm on cm.user_id = u.id '
sql += 'where cm.community_id = :community_id and cm.is_banned = false and i.id <> 1'
sql += 'where cm.community_id = :community_id and cm.is_banned = false and i.id <> 1 and i.dormant = false and i.gone_forever = false'
return db.session.execute(text(sql), {'community_id': self.id})
def delete_dependencies(self):
@ -898,6 +899,17 @@ class Report(db.Model):
created_at = db.Column(db.DateTime, default=utcnow)
updated = db.Column(db.DateTime, default=utcnow)
# textual representation of self.type
def type_text(self):
types = ('User', 'Post', 'Comment', 'Community')
if self.type is None:
return ''
else:
return types[self.type]
def is_local(self):
return True
class IpBan(db.Model):
id = db.Column(db.Integer, primary_key=True)

View file

@ -18,7 +18,7 @@ from app.models import Post, PostReply, \
from app.post import bp
from app.utils import get_setting, render_template, allowlist_html, markdown_to_html, validation_required, \
shorten_string, markdown_to_text, domain_from_url, validate_image, gibberish, ap_datetime, return_304, \
request_etag_matches, ip_address, user_ip_banned
request_etag_matches, ip_address, user_ip_banned, instance_banned
def show_post(post_id: int):
@ -68,8 +68,18 @@ def show_post(post_id: int):
db.session.add(reply)
db.session.commit()
reply.ap_id = reply.profile_id()
reply_vote = PostReplyVote(user_id=current_user.id, author_id=current_user.id, post_reply_id=reply.id,
if current_user.reputation > 100:
reply_vote = PostReplyVote(user_id=1, author_id=current_user.id, post_reply_id=reply.id,
effect=1.0)
reply.up_votes += 1
reply.score += 1
reply.ranking += 1
db.session.add(reply_vote)
elif current_user.reputation < -100:
reply_vote = PostReplyVote(user_id=1, author_id=current_user.id, post_reply_id=reply.id,
effect=-1.0)
reply.score -= 1
reply.ranking -= 1
db.session.add(reply_vote)
db.session.commit()
form.body.data = ''
@ -133,7 +143,7 @@ def show_post(post_id: int):
}
for instance in post.community.following_instances():
if instance[1] and not current_user.has_blocked_instance(instance[0]):
if instance[1] and not current_user.has_blocked_instance(instance[0]) and not instance_banned(instance[1]):
send_to_remote_instance(instance[1], post.community.id, announce)
return redirect(url_for('activitypub.post_ap', post_id=post_id)) # redirect to current page to avoid refresh resubmitting the form
@ -225,7 +235,7 @@ def post_vote(post_id: int, vote_direction):
'object': action_json
}
for instance in post.community.following_instances():
if instance[1] and not current_user.has_blocked_instance(instance[0]):
if instance[1] and not current_user.has_blocked_instance(instance[0]) and not instance_banned(instance[1]):
send_to_remote_instance(instance[1], post.community.id, announce)
else:
success = post_request(post.community.ap_inbox_url, action_json, current_user.private_key,
@ -368,8 +378,18 @@ def add_reply(post_id: int, comment_id: int):
db.session.commit()
reply.ap_id = reply.profile_id()
db.session.commit()
reply_vote = PostReplyVote(user_id=current_user.id, author_id=current_user.id, post_reply_id=reply.id,
if current_user.reputation > 100:
reply_vote = PostReplyVote(user_id=1, author_id=current_user.id, post_reply_id=reply.id,
effect=1.0)
reply.up_votes += 1
reply.score += 1
reply.ranking += 1
db.session.add(reply_vote)
elif current_user.reputation < -100:
reply_vote = PostReplyVote(user_id=1, author_id=current_user.id, post_reply_id=reply.id,
effect=-1.0)
reply.score -= 1
reply.ranking -= 1
db.session.add(reply_vote)
post.reply_count = post_reply_count(post.id)
post.last_active = post.community.last_active = utcnow()
@ -452,7 +472,7 @@ def add_reply(post_id: int, comment_id: int):
}
for instance in post.community.following_instances():
if instance[1] and not current_user.has_blocked_instance(instance[0]):
if instance[1] and not current_user.has_blocked_instance(instance[0]) and not instance_banned(instance[1]):
send_to_remote_instance(instance[1], post.community.id, announce)
if reply.depth <= constants.THREAD_CUTOFF_DEPTH:

View file

@ -570,6 +570,10 @@ nav.navbar {
width: 96%;
}
.reported {
background-color: antiquewhite;
}
@media (prefers-color-scheme: dark) {
body {
background-color: #777;

View file

@ -254,6 +254,10 @@ nav.navbar {
width: 96%;
}
.reported {
background-color: antiquewhite;
}
@media (prefers-color-scheme: dark) {
body {
background-color: $dark-grey;

View file

@ -4,6 +4,7 @@
<a href="{{ url_for('admin.admin_misc') }}">{{ _('Misc settings') }}</a> |
<a href="{{ url_for('admin.admin_communities') }}">{{ _('Communities') }}</a> |
<a href="{{ url_for('admin.admin_users') }}">{{ _('Users') }}</a> |
<a href="{{ url_for('admin.admin_reports') }}">{{ _('Moderation') }}</a> |
<a href="{{ url_for('admin.admin_federation') }}">{{ _('Federation') }}</a> |
<a href="{{ url_for('admin.admin_activities') }}">{{ _('Activities') }}</a>
</nav>

View file

@ -0,0 +1,63 @@
{% extends "base.html" %}
{% from 'bootstrap/form.html' import render_form %}
{% block app_content %}
<div class="row">
<div class="col">
{% include 'admin/_nav.html' %}
</div>
</div>
<div class="row">
<div class="col">
<form method="get">
<input type="search" name="search" value="{{ search }}">
<input type="radio" name="local_remote" value="local" id="local_remote_local" {{ 'checked' if local_remote == 'local' }}><label for="local_remote_local"> Local</label>
<input type="radio" name="local_remote" value="remote" id="local_remote_remote" {{ 'checked' if local_remote == 'remote' }}><label for="local_remote_remote"> Remote</label>
<input type="submit" name="submit" value="Search" class="btn btn-primary">
</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>
</tr>
{% for report in reports %}
<tr>
<td>{{ 'Local' if report.is_local() else 'Remote' }}</td>
<td>{{ report.reasons }}</td>
<td>{{ report.description }}</td>
<td>{{ report.type_text() }}</td>
<td>{{ report.created_at }}</td>
<td>
{% if report.suspect_post_reply_id %}
<a href="/post/{{ report.suspect_post_id }}#comment_{{ report.suspect_post_reply_id }}">View</a>
{% elif report.suspect_post_id %}
<a href="/post/{{ report.suspect_post_id }}">View</a>
{% elif report.suspect_user_id %}
<a href="/user/{{ report.suspect_user_id }}">View</a>
{% elif report.suspect_community_id %}
<a href="/user/{{ report.suspect_community_id }}">View</a>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
<nav aria-label="Pagination" class="mt-4">
{% if prev_url %}
<a href="{{ prev_url }}" class="btn btn-primary">
<span aria-hidden="true">&larr;</span> {{ _('Previous page') }}
</a>
{% endif %}
{% if next_url %}
<a href="{{ next_url }}" class="btn btn-primary">
{{ _('Next page') }} <span aria-hidden="true">&rarr;</span>
</a>
{% endif %}
</nav>
</div>
</div>
{% endblock %}

View file

@ -11,9 +11,9 @@
<div class="row">
<div class="col">
<form method="get">
<input type="search" name="search">
<input type="radio" name="local_remote" value="local" id="local_remote_local"><label for="local_remote_local"> Local</label>
<input type="radio" name="local_remote" value="remote" id="local_remote_remote"><label for="local_remote_remote"> Remote</label>
<input type="search" name="search" value="{{ search }}">
<input type="radio" name="local_remote" value="local" id="local_remote_local" {{ 'checked' if local_remote == 'local' }}><label for="local_remote_local"> Local</label>
<input type="radio" name="local_remote" value="remote" id="local_remote_remote" {{ 'checked' if local_remote == 'remote' }}><label for="local_remote_remote"> Remote</label>
<input type="submit" name="submit" value="Search" class="btn btn-primary">
</form>
<table class="table table-striped">

View file

@ -18,7 +18,9 @@
<img src="/static/images/external_link_black.svg" class="external_link_icon" alt="External link" />
</a></small></p>
{% endif %}
<p><small>submitted {{ moment(post.posted_at).fromNow() }} by {{ render_username(post.author) }}
<p>{% if post.reports and current_user.is_authenticated and post.community.is_moderator(current_user) %}
<span class="red fe fe-report" title="{{ _('Reported. Check post for issues.') }}"></span>
{% endif %}<small>submitted {{ moment(post.posted_at).fromNow() }} by {{ render_username(post.author) }}
{% if post.edited_at %} edited {{ moment(post.edited_at).fromNow() }}{% endif %}
</small></p>
<div class="post_image">
@ -46,9 +48,11 @@
width="{{ post.image.thumbnail_width }}" height="{{ post.image.thumbnail_height }}" /></a>
</div>
{% endif %}
<p class="small">submitted {{ moment(post.posted_at).fromNow() }} by
<p>{% if post.reports and current_user.is_authenticated and post.community.is_moderator(current_user) %}
<span class="red fe fe-report" title="{{ _('Reported. Check post for issues.') }}"></span>
{% endif %}<small>submitted {{ moment(post.posted_at).fromNow() }} by
{{ render_username(post.author) }}
{% if post.edited_at %} edited {{ moment(post.edited_at).fromNow() }}{% endif %}
{% if post.edited_at %} edited {{ moment(post.edited_at).fromNow() }}{% endif %}</small>
</p>
{% if post.type == POST_TYPE_LINK %}
<p><small><a href="{{ post.url }}" rel="nofollow ugc">{{ post.url|shorten_url }}

View file

@ -1,4 +1,4 @@
<div class="post_teaser">
<div class="post_teaser {{ 'reported' if post.reports and current_user.is_authenticated and post.community.is_moderator() }}">
<div class="row">
<div class="col-12">
<div class="row main_row">
@ -29,6 +29,9 @@
</a>
<span class="domain_link">(<a href="/d/{{ post.domain_id }}">{{ post.domain.name }}</a>)</span>
{% endif %}
{% if post.reports and current_user.is_authenticated and post.community.is_moderator(current_user) %}
<span class="red fe fe-report" title="{{ _('Reported. Check post for issues.') }}"></span>
{% endif %}
</h3>
<span class="small">{{ render_username(post.author) }} · {{ moment(post.posted_at).fromNow() }}</span>

View file

@ -84,8 +84,11 @@
{% endif %}
{% if comment['comment'].author.id == post.author.id %}<span title="Submitter of original post" aria-label="submitter" class="small">[OP]</span>{% endif %}
<span class="text-muted small">{{ moment(comment['comment'].posted_at).fromNow(refresh=True) }}{% if comment['comment'].edited_at %}, edited {{ moment(comment['comment'].edited_at).fromNow(refresh=True) }} {% endif %}</span>
{% if comment['comment'].reports and current_user.is_authenticated and post.community.is_moderator(current_user)%}
<span class="red fe fe-report" title="{{ _('Reported. Check comment for issues.') }}"></span>
{% endif %}
</div>
<div class="comment_body hidable">
<div class="comment_body hidable {% if comment['comment'].reports and current_user.is_authenticated and post.community.is_moderator(current_user) %}reported{% endif %}">
{{ comment['comment'].body_html | safe }}
</div>
</div>

View file

@ -360,6 +360,11 @@ def user_ip_banned() -> bool:
return current_ip_address in banned_ip_addresses()
def instance_banned(domain: str) -> bool:
banned = BannedInstances.query.filter_by(domain=domain).first()
return banned is not None
def user_cookie_banned() -> bool:
cookie = request.cookies.get('sesion', None)
return cookie is not None