community owners can change settings and appoint moderators #21

This commit is contained in:
rimu 2024-03-13 16:40:20 +13:00
parent 8b4b0c2e7f
commit 4fc715bb18
19 changed files with 401 additions and 38 deletions

View file

@ -21,7 +21,8 @@ from app.activitypub.util import public_key, users_total, active_half_year, acti
update_post_from_activity, undo_vote, undo_downvote update_post_from_activity, undo_vote, undo_downvote
from app.utils import gibberish, get_setting, is_image_url, allowlist_html, html_to_markdown, render_template, \ from app.utils import gibberish, get_setting, is_image_url, allowlist_html, html_to_markdown, render_template, \
domain_from_url, markdown_to_html, community_membership, ap_datetime, markdown_to_text, ip_address, can_downvote, \ domain_from_url, markdown_to_html, community_membership, ap_datetime, markdown_to_text, ip_address, can_downvote, \
can_upvote, can_create_post, awaken_dormant_instance, shorten_string, can_create_post_reply, sha256_digest can_upvote, can_create_post, awaken_dormant_instance, shorten_string, can_create_post_reply, sha256_digest, \
community_moderators
import werkzeug.exceptions import werkzeug.exceptions
@ -987,11 +988,11 @@ def community_outbox(actor):
@bp.route('/c/<actor>/moderators', methods=['GET']) @bp.route('/c/<actor>/moderators', methods=['GET'])
def community_moderators(actor): def community_moderators_route(actor):
actor = actor.strip() actor = actor.strip()
community = Community.query.filter_by(name=actor, banned=False, ap_id=None).first() community = Community.query.filter_by(name=actor, banned=False, ap_id=None).first()
if community is not None: if community is not None:
moderator_ids = community.moderators() moderator_ids = community_moderators(community.id)
moderators = User.query.filter(User.id.in_([mod.user_id for mod in moderator_ids])).all() moderators = User.query.filter(User.id.in_([mod.user_id for mod in moderator_ids])).all()
community_data = { community_data = {
"@context": default_context(), "@context": default_context(),

View file

@ -52,6 +52,7 @@ class EditCommunityForm(FlaskForm):
banner_file = FileField(_('Banner image')) banner_file = FileField(_('Banner image'))
rules = TextAreaField(_l('Rules')) rules = TextAreaField(_l('Rules'))
nsfw = BooleanField(_l('Porn community')) nsfw = BooleanField(_l('Porn community'))
banned = BooleanField(_l('Banned - no new posts accepted'))
local_only = BooleanField(_l('Only accept posts from current instance')) local_only = BooleanField(_l('Only accept posts from current instance'))
restricted_to_mods = BooleanField(_l('Only moderators can post')) restricted_to_mods = BooleanField(_l('Only moderators can post'))
new_mods_wanted = BooleanField(_l('New moderators wanted')) new_mods_wanted = BooleanField(_l('New moderators wanted'))

View file

@ -199,7 +199,7 @@ def admin_communities():
page = request.args.get('page', 1, type=int) page = request.args.get('page', 1, type=int)
search = request.args.get('search', '') search = request.args.get('search', '')
communities = Community.query.filter_by(banned=False) communities = Community.query
if search: if search:
communities = communities.filter(Community.title.ilike(f"%{search}%")) communities = communities.filter(Community.title.ilike(f"%{search}%"))
communities = communities.order_by(Community.title).paginate(page=page, per_page=1000, error_out=False) communities = communities.order_by(Community.title).paginate(page=page, per_page=1000, error_out=False)
@ -228,6 +228,7 @@ def admin_community_edit(community_id):
community.rules = form.rules.data community.rules = form.rules.data
community.rules_html = markdown_to_html(form.rules.data) community.rules_html = markdown_to_html(form.rules.data)
community.nsfw = form.nsfw.data community.nsfw = form.nsfw.data
community.banned = form.banned.data
community.local_only = form.local_only.data community.local_only = form.local_only.data
community.restricted_to_mods = form.restricted_to_mods.data community.restricted_to_mods = form.restricted_to_mods.data
community.new_mods_wanted = form.new_mods_wanted.data community.new_mods_wanted = form.new_mods_wanted.data
@ -255,7 +256,8 @@ def admin_community_edit(community_id):
community.image = file community.image = file
db.session.commit() db.session.commit()
community.topic.num_communities = community.topic.communities.count() if community.topic_id:
community.topic.num_communities = community.topic.communities.count()
db.session.commit() db.session.commit()
flash(_('Saved')) flash(_('Saved'))
return redirect(url_for('admin.admin_communities')) return redirect(url_for('admin.admin_communities'))
@ -267,6 +269,7 @@ def admin_community_edit(community_id):
form.description.data = community.description form.description.data = community.description
form.rules.data = community.rules form.rules.data = community.rules
form.nsfw.data = community.nsfw form.nsfw.data = community.nsfw
form.banned.data = community.banned
form.local_only.data = community.local_only form.local_only.data = community.local_only
form.new_mods_wanted.data = community.new_mods_wanted form.new_mods_wanted.data = community.new_mods_wanted
form.restricted_to_mods.data = community.restricted_to_mods form.restricted_to_mods.data = community.restricted_to_mods

View file

@ -18,7 +18,7 @@ from app.chat import bp
def chat_home(conversation_id=None): def chat_home(conversation_id=None):
form = AddReply() form = AddReply()
if form.validate_on_submit(): if form.validate_on_submit():
reply = send_message(form, conversation_id) reply = send_message(form.message.data, conversation_id)
return redirect(url_for('chat.chat_home', conversation_id=conversation_id, _anchor=f'message_{reply.id}')) return redirect(url_for('chat.chat_home', conversation_id=conversation_id, _anchor=f'message_{reply.id}'))
else: else:
conversations = Conversation.query.join(conversation_member, conversations = Conversation.query.join(conversation_member,
@ -73,7 +73,7 @@ def new_message(to):
conversation.members.append(current_user) conversation.members.append(current_user)
db.session.add(conversation) db.session.add(conversation)
db.session.commit() db.session.commit()
reply = send_message(form, conversation.id) reply = send_message(form.message.data, conversation.id)
return redirect(url_for('chat.chat_home', conversation_id=conversation.id, _anchor=f'message_{reply.id}')) return redirect(url_for('chat.chat_home', conversation_id=conversation.id, _anchor=f'message_{reply.id}'))
else: else:
return render_template('chat/new_message.html', form=form, title=_('New message to "%(recipient_name)s"', recipient_name=recipient.link()), return render_template('chat/new_message.html', form=form, title=_('New message to "%(recipient_name)s"', recipient_name=recipient.link()),

View file

@ -9,10 +9,10 @@ from app.models import User, ChatMessage, Notification, utcnow, Conversation
from app.utils import allowlist_html, shorten_string, gibberish, markdown_to_html from app.utils import allowlist_html, shorten_string, gibberish, markdown_to_html
def send_message(form, conversation_id: int) -> ChatMessage: def send_message(message: str, conversation_id: int) -> ChatMessage:
conversation = Conversation.query.get(conversation_id) conversation = Conversation.query.get(conversation_id)
reply = ChatMessage(sender_id=current_user.id, conversation_id=conversation.id, reply = ChatMessage(sender_id=current_user.id, conversation_id=conversation.id,
body=form.message.data, body_html=allowlist_html(markdown_to_html(form.message.data))) body=message, body_html=allowlist_html(markdown_to_html(message)))
for recipient in conversation.members: for recipient in conversation.members:
if recipient.id != current_user.id: if recipient.id != current_user.id:
if recipient.is_local(): if recipient.is_local():

View file

@ -13,7 +13,7 @@ from io import BytesIO
import pytesseract import pytesseract
class AddLocalCommunity(FlaskForm): class AddCommunityForm(FlaskForm):
community_name = StringField(_l('Name'), validators=[DataRequired()]) community_name = StringField(_l('Name'), validators=[DataRequired()])
url = StringField(_l('Url')) url = StringField(_l('Url'))
description = TextAreaField(_l('Description')) description = TextAreaField(_l('Description'))
@ -37,6 +37,29 @@ class AddLocalCommunity(FlaskForm):
return True return True
class EditCommunityForm(FlaskForm):
title = StringField(_l('Title'), validators=[DataRequired()])
description = TextAreaField(_l('Description'))
icon_file = FileField(_('Icon image'))
banner_file = FileField(_('Banner image'))
rules = TextAreaField(_l('Rules'))
nsfw = BooleanField(_l('Porn community'))
local_only = BooleanField(_l('Only accept posts from current instance'))
restricted_to_mods = BooleanField(_l('Only moderators can post'))
new_mods_wanted = BooleanField(_l('New moderators wanted'))
topic = SelectField(_l('Topic'), coerce=int, validators=[Optional()])
layouts = [('', _l('List')),
('masonry', _l('Masonry')),
('masonry_wide', _l('Wide masonry'))]
default_layout = SelectField(_l('Layout'), coerce=str, choices=layouts, validators=[Optional()])
submit = SubmitField(_l('Save'))
class AddModeratorForm(FlaskForm):
user_name = StringField(_l('User name'), validators=[DataRequired()])
submit = SubmitField(_l('Add'))
class SearchRemoteCommunity(FlaskForm): class SearchRemoteCommunity(FlaskForm):
address = StringField(_l('Community address'), render_kw={'placeholder': 'e.g. !name@server', 'autofocus': True}, validators=[DataRequired()]) address = StringField(_l('Community address'), render_kw={'placeholder': 'e.g. !name@server', 'autofocus': True}, validators=[DataRequired()])
submit = SubmitField(_l('Search')) submit = SubmitField(_l('Search'))

View file

@ -9,21 +9,24 @@ from sqlalchemy import or_, desc
from app import db, constants, cache from app import db, constants, cache
from app.activitypub.signature import RsaKeys, post_request from app.activitypub.signature import RsaKeys, post_request
from app.activitypub.util import default_context, notify_about_post from app.activitypub.util import default_context, notify_about_post, find_actor_or_create
from app.community.forms import SearchRemoteCommunity, AddLocalCommunity, CreatePostForm, ReportCommunityForm, \ from app.chat.util import send_message
DeleteCommunityForm from app.community.forms import SearchRemoteCommunity, CreatePostForm, ReportCommunityForm, \
DeleteCommunityForm, AddCommunityForm, EditCommunityForm, AddModeratorForm
from app.community.util import search_for_community, community_url_exists, actor_to_community, \ from app.community.util import search_for_community, community_url_exists, actor_to_community, \
opengraph_parse, url_to_thumbnail_file, save_post, save_icon_file, save_banner_file, send_to_remote_instance opengraph_parse, url_to_thumbnail_file, save_post, save_icon_file, save_banner_file, send_to_remote_instance
from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, POST_TYPE_LINK, POST_TYPE_ARTICLE, POST_TYPE_IMAGE, \ from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, POST_TYPE_LINK, POST_TYPE_ARTICLE, POST_TYPE_IMAGE, \
SUBSCRIPTION_PENDING, SUBSCRIPTION_MODERATOR SUBSCRIPTION_PENDING, SUBSCRIPTION_MODERATOR
from app.inoculation import inoculation from app.inoculation import inoculation
from app.models import User, Community, CommunityMember, CommunityJoinRequest, CommunityBan, Post, \ from app.models import User, Community, CommunityMember, CommunityJoinRequest, CommunityBan, Post, \
File, PostVote, utcnow, Report, Notification, InstanceBlock, ActivityPubLog, Topic File, PostVote, utcnow, Report, Notification, InstanceBlock, ActivityPubLog, Topic, Conversation
from app.community import bp 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, \ from app.utils import get_setting, render_template, allowlist_html, markdown_to_html, validation_required, \
shorten_string, gibberish, community_membership, ap_datetime, \ shorten_string, gibberish, community_membership, ap_datetime, \
request_etag_matches, return_304, instance_banned, can_create_post, can_upvote, can_downvote, user_filters_posts, \ 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 joined_communities, moderating_communities, blocked_domains, mimetype_from_url, blocked_instances, \
community_moderators
from feedgen.feed import FeedGenerator from feedgen.feed import FeedGenerator
from datetime import timezone, timedelta from datetime import timezone, timedelta
@ -32,7 +35,7 @@ from datetime import timezone, timedelta
@login_required @login_required
def add_local(): def add_local():
flash('PieFed is still being tested so hosting communities on piefed.social is not advised except for testing purposes.', 'warning') flash('PieFed is still being tested so hosting communities on piefed.social is not advised except for testing purposes.', 'warning')
form = AddLocalCommunity() form = AddCommunityForm()
if g.site.enable_nsfw is False: if g.site.enable_nsfw is False:
form.nsfw.render_kw = {'disabled': True} form.nsfw.render_kw = {'disabled': True}
@ -124,7 +127,7 @@ def show_community(community: Community):
if current_user.is_anonymous and request_etag_matches(current_etag): if current_user.is_anonymous and request_etag_matches(current_etag):
return return_304(current_etag) return return_304(current_etag)
mods = community.moderators() mods = community_moderators(community.id)
is_moderator = current_user.is_authenticated and any(mod.user_id == current_user.id for mod in mods) is_moderator = current_user.is_authenticated and any(mod.user_id == current_user.id for mod in mods)
is_owner = current_user.is_authenticated and any( is_owner = current_user.is_authenticated and any(
@ -584,6 +587,65 @@ def community_report(community_id: int):
return render_template('community/community_report.html', title=_('Report community'), form=form, community=community) return render_template('community/community_report.html', title=_('Report community'), form=form, community=community)
@bp.route('/community/<int:community_id>/edit', methods=['GET', 'POST'])
@login_required
def community_edit(community_id: int):
from app.admin.util import topics_for_form
community = Community.query.get_or_404(community_id)
if community.is_owner() or current_user.is_admin():
form = EditCommunityForm()
form.topic.choices = topics_for_form(0)
if form.validate_on_submit():
community.title = form.title.data
community.description = form.description.data
community.description_html = markdown_to_html(form.description.data)
community.rules = form.rules.data
community.rules_html = markdown_to_html(form.rules.data)
community.nsfw = form.nsfw.data
community.local_only = form.local_only.data
community.restricted_to_mods = form.restricted_to_mods.data
community.new_mods_wanted = form.new_mods_wanted.data
community.topic_id = form.topic.data if form.topic.data != 0 else None
community.default_layout = form.default_layout.data
icon_file = request.files['icon_file']
if icon_file and icon_file.filename != '':
if community.icon_id:
community.icon.delete_from_disk()
file = save_icon_file(icon_file)
if file:
community.icon = file
banner_file = request.files['banner_file']
if banner_file and banner_file.filename != '':
if community.image_id:
community.image.delete_from_disk()
file = save_banner_file(banner_file)
if file:
community.image = file
db.session.commit()
community.topic.num_communities = community.topic.communities.count()
db.session.commit()
flash(_('Saved'))
return redirect(url_for('activitypub.community_profile', actor=community.ap_id if community.ap_id is not None else community.name))
else:
form.title.data = community.title
form.description.data = community.description
form.rules.data = community.rules
form.nsfw.data = community.nsfw
form.local_only.data = community.local_only
form.new_mods_wanted.data = community.new_mods_wanted
form.restricted_to_mods.data = community.restricted_to_mods
form.topic.data = community.topic_id if community.topic_id else None
form.default_layout.data = community.default_layout
return render_template('community/community_edit.html', title=_('Edit community'), form=form,
current_app=current_app,
community=community, moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.get_id()))
else:
abort(401)
@bp.route('/community/<int:community_id>/delete', methods=['GET', 'POST']) @bp.route('/community/<int:community_id>/delete', methods=['GET', 'POST'])
@login_required @login_required
def community_delete(community_id: int): def community_delete(community_id: int):
@ -608,6 +670,96 @@ def community_delete(community_id: int):
abort(401) abort(401)
@bp.route('/community/<int:community_id>/moderators', methods=['GET', 'POST'])
@login_required
def community_mod_list(community_id: int):
community = Community.query.get_or_404(community_id)
if community.is_owner() or current_user.is_admin():
moderators = User.query.filter(User.banned == False).join(CommunityMember, CommunityMember.user_id == User.id).\
filter(CommunityMember.community_id == community_id, or_(CommunityMember.is_moderator == True, CommunityMember.is_owner == True)).all()
return render_template('community/community_mod_list.html', title=_('Moderators for %(community)s', community=community.display_name()),
moderators=moderators, community=community,
moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.get_id())
)
@bp.route('/community/<int:community_id>/moderators/add', methods=['GET', 'POST'])
@login_required
def community_add_moderator(community_id: int):
community = Community.query.get_or_404(community_id)
if community.is_owner() or current_user.is_admin():
form = AddModeratorForm()
if form.validate_on_submit():
new_moderator = search_for_user(form.user_name.data)
if new_moderator:
existing_member = CommunityMember.query.filter(CommunityMember.user_id == new_moderator.id, CommunityMember.community_id == community_id).first()
if existing_member:
existing_member.is_moderator = True
else:
new_member = CommunityMember(community_id=community_id, user_id=new_moderator.id, is_moderator=True)
db.session.add(new_member)
db.session.commit()
flash(_('Moderator added'))
# Notify new mod
if new_moderator.is_local():
notify = Notification(title=_('You are now a moderator of %(name)s', name=community.display_name()),
url='/c/' + community.name, user_id=new_moderator.id,
author_id=current_user.id)
new_moderator.unread_notifications += 1
db.session.add(notify)
db.session.commit()
else:
# for remote users, send a chat message to let them know
existing_conversation = Conversation.find_existing_conversation(recipient=new_moderator,
sender=current_user)
if not existing_conversation:
existing_conversation = Conversation(user_id=current_user.id)
existing_conversation.members.append(new_moderator)
existing_conversation.members.append(current_user)
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)
# Flush cache
cache.delete_memoized(moderating_communities, new_moderator.id)
cache.delete_memoized(joined_communities, new_moderator.id)
cache.delete_memoized(community_moderators, community_id)
return redirect(url_for('community.community_mod_list', community_id=community.id))
else:
flash(_('Account not found'), 'warning')
return render_template('community/community_add_moderator.html', title=_('Add moderator to %(community)s', community=community.display_name()),
community=community, form=form,
moderating_communities=moderating_communities(current_user.get_id()),
joined_communities=joined_communities(current_user.get_id())
)
@bp.route('/community/<int:community_id>/moderators/remove/<int:user_id>', methods=['GET', 'POST'])
@login_required
def community_remove_moderator(community_id: int, user_id: int):
community = Community.query.get_or_404(community_id)
if community.is_owner() or current_user.is_admin():
existing_member = CommunityMember.query.filter(CommunityMember.user_id == user_id,
CommunityMember.community_id == community_id).first()
if existing_member:
existing_member.is_moderator = False
db.session.commit()
flash(_('Moderator removed'))
# Flush cache
cache.delete_memoized(moderating_communities, user_id)
cache.delete_memoized(joined_communities, user_id)
cache.delete_memoized(community_moderators, community_id)
return redirect(url_for('community.community_mod_list', community_id=community.id))
@bp.route('/community/<int:community_id>/block_instance', methods=['GET', 'POST']) @bp.route('/community/<int:community_id>/block_instance', methods=['GET', 'POST'])
@login_required @login_required
def community_block_instance(community_id: int): def community_block_instance(community_id: int):

View file

@ -342,7 +342,7 @@ class Community(db.Model):
else: else:
return self.ap_id.lower() return self.ap_id.lower()
@cache.memoize(timeout=30) @cache.memoize(timeout=3)
def moderators(self): def moderators(self):
return CommunityMember.query.filter((CommunityMember.community_id == self.id) & return CommunityMember.query.filter((CommunityMember.community_id == self.id) &
(or_( (or_(

View file

@ -23,7 +23,7 @@ from app.utils import get_setting, render_template, allowlist_html, markdown_to_
shorten_string, markdown_to_text, gibberish, ap_datetime, return_304, \ shorten_string, markdown_to_text, gibberish, ap_datetime, return_304, \
request_etag_matches, ip_address, user_ip_banned, instance_banned, can_downvote, can_upvote, post_ranking, \ request_etag_matches, ip_address, user_ip_banned, instance_banned, can_downvote, can_upvote, post_ranking, \
reply_already_exists, reply_is_just_link_to_gif_reaction, confidence, moderating_communities, joined_communities, \ reply_already_exists, reply_is_just_link_to_gif_reaction, confidence, moderating_communities, joined_communities, \
blocked_instances, blocked_domains blocked_instances, blocked_domains, community_moderators
def show_post(post_id: int): def show_post(post_id: int):
@ -43,7 +43,7 @@ def show_post(post_id: int):
if post.mea_culpa: if post.mea_culpa:
flash(_('%(name)s has indicated they made a mistake in this post.', name=post.author.user_name), 'warning') flash(_('%(name)s has indicated they made a mistake in this post.', name=post.author.user_name), 'warning')
mods = community.moderators() mods = community_moderators(community.id)
is_moderator = current_user.is_authenticated and any(mod.user_id == current_user.id for mod in mods) is_moderator = current_user.is_authenticated and any(mod.user_id == current_user.id for mod in mods)
# handle top-level comments/replies # handle top-level comments/replies

View file

@ -1217,6 +1217,12 @@ fieldset legend {
color: black; color: black;
} }
h1 .warning_badge {
position: relative;
left: 15px;
top: -6px;
}
[data-bs-theme=dark] .warning_badge.nsfl { [data-bs-theme=dark] .warning_badge.nsfl {
border: 1px solid white; border: 1px solid white;
color: white; color: white;
@ -1288,15 +1294,4 @@ fieldset legend {
max-width: 100%; max-width: 100%;
} }
@media (orientation: portrait) {
.flex-sm-fill.text-sm-center.nav-link.active{
border-bottom: var(--bs-nav-tabs-border-width) solid var(--bs-border-color);
border-bottom-right-radius: var(--bs-nav-tabs-border-radius);
border-bottom-left-radius: var(--bs-nav-tabs-border-radius);
}
.card-header-tabs {
margin-bottom:8px;
}
}
/*# sourceMappingURL=structure.css.map */ /*# sourceMappingURL=structure.css.map */

View file

@ -900,6 +900,13 @@ fieldset {
color:black; color:black;
} }
} }
h1 .warning_badge {
position: relative;
left: 15px;
top: -6px;
}
[data-bs-theme=dark] .warning_badge.nsfl { [data-bs-theme=dark] .warning_badge.nsfl {
border:1px solid white; border:1px solid white;
color:white; color:white;

View file

@ -32,7 +32,7 @@
<tr> <tr>
<td>{{ community.name }}</td> <td>{{ community.name }}</td>
<td><img src="{{ community.icon_image('tiny') }}" class="community_icon rounded-circle" loading="lazy" /> <td><img src="{{ community.icon_image('tiny') }}" class="community_icon rounded-circle" loading="lazy" />
{{ community.display_name() }}</td> {{ community.display_name() }}{% if community.banned %} (banned){% endif %}</td>
<td>{{ community.topic.name }}</td> <td>{{ community.topic.name }}</td>
<td>{{ community.post_count }}</td> <td>{{ community.post_count }}</td>
<th>{{ '&check;'|safe if community.show_home else '&cross;'|safe }}</th> <th>{{ '&check;'|safe if community.show_home else '&cross;'|safe }}</th>

View file

@ -42,6 +42,7 @@
<fieldset class="border pl-2 pt-2 mb-4"> <fieldset class="border pl-2 pt-2 mb-4">
<legend>{{ _('Will not be overwritten by remote server') }}</legend> <legend>{{ _('Will not be overwritten by remote server') }}</legend>
{% endif %} {% endif %}
{{ render_field(form.banned) }}
{{ render_field(form.local_only) }} {{ render_field(form.local_only) }}
{{ render_field(form.new_mods_wanted) }} {{ render_field(form.new_mods_wanted) }}
{{ render_field(form.show_home) }} {{ render_field(form.show_home) }}

View file

@ -24,6 +24,8 @@
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
{% include 'community/_notification_toggle.html' %} {% include 'community/_notification_toggle.html' %}
{% endif %} {% endif %}
{% if community.nsfw %}<span class="warning_badge nsfw" title="{{ _('Not safe for work') }}">nsfw</span>{% endif %}
{% if community.nsfl %}<span class="warning_badge nsfl" title="{{ _('Not safe for life') }}">nsfl</span>{% endif %}
</h1> </h1>
{% elif community.icon_id and not low_bandwidth %} {% elif community.icon_id and not low_bandwidth %}
<div class="row"> <div class="row">
@ -43,6 +45,8 @@
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
{% include 'community/_notification_toggle.html' %} {% include 'community/_notification_toggle.html' %}
{% endif %} {% endif %}
{% if community.nsfw %}<span class="warning_badge nsfw" title="{{ _('Not safe for work') }}">nsfw</span>{% endif %}
{% if community.nsfl %}<span class="warning_badge nsfl" title="{{ _('Not safe for life') }}">nsfl</span>{% endif %}
</h1> </h1>
</div> </div>
</div> </div>
@ -59,6 +63,8 @@
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
{% include 'community/_notification_toggle.html' %} {% include 'community/_notification_toggle.html' %}
{% endif %} {% endif %}
{% if community.nsfw %}<span class="warning_badge nsfw" title="{{ _('Not safe for work') }}">nsfw</span>{% endif %}
{% if community.nsfl %}<span class="warning_badge nsfl" title="{{ _('Not safe for life') }}">nsfl</span>{% endif %}
</h1> </h1>
{% endif %} {% endif %}
{% include "community/_community_nav.html" %} {% include "community/_community_nav.html" %}
@ -170,8 +176,8 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<p><a href="#" class="btn btn-primary">{{ _('Moderate') }}</a></p> <p><a href="#" class="btn btn-primary">{{ _('Moderate') }}</a></p>
<p><a href="#" class="btn btn-primary">{{ _('Settings') }}</a></p> <p><a href="{{ url_for('community.community_edit', community_id=community.id) }}" class="btn btn-primary">{{ _('Settings') }}</a></p>
{% if community.is_owner() or current_user.is_admin() %} {% if community.is_local() and (community.is_owner() or current_user.is_admin()) %}
<p><a class="btn btn-primary btn-warning" href="{{ url_for('community.community_delete', community_id=community.id) }}" rel="nofollow">Delete community</a></p> <p><a class="btn btn-primary btn-warning" href="{{ url_for('community.community_delete', community_id=community.id) }}" rel="nofollow">Delete community</a></p>
{% endif %} {% endif %}
</div> </div>

View file

@ -0,0 +1,19 @@
{% 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_form %}
{% block app_content %}
<div class="row">
<div class="col col-login mx-auto">
<div class="card mt-5">
<div class="card-body p-6">
<div class="card-title">{{ _('Add moderator to %(community)s', community=community.display_name()) }}</div>
{{ render_form(form) }}
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,58 @@
{% 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 active">{{ _('Settings') }}</li>
</ol>
</nav>
<h1 class="mt-2">
{% if community %}
{{ _('Edit community') }}
{% else %}
{{ _('Create community') }}
{% endif %}
</h1>
<form method="post" enctype="multipart/form-data" id="add_local_community_form" role="form">
{{ form.csrf_token() }}
{{ render_field(form.title) }}
{{ render_field(form.description) }}
{% if community.icon_id %}
<img class="community_icon_big rounded-circle" src="{{ community.icon_image() }}" />
{% endif %}
{{ render_field(form.icon_file) }}
<small class="field_hint">Provide a square image that looks good when small.</small>
{% if community.image_id %}
<a href="{{ community.header_image() }}"><img class="community_icon_big" src="{{ community.header_image() }}" /></a>
{% endif %}
{{ render_field(form.banner_file) }}
<small class="field_hint">Provide a wide image - letterbox orientation.</small>
{{ render_field(form.rules) }}
{{ render_field(form.nsfw) }}
{{ render_field(form.restricted_to_mods) }}
{{ render_field(form.local_only) }}
{{ render_field(form.new_mods_wanted) }}
{{ render_field(form.topic) }}
{{ render_field(form.default_layout) }}
<div class="row">
<div class="col-auto">
{{ render_field(form.submit) }}
</div>
<div class="col-auto">
<a class="btn btn-outline-secondary" href="{{ url_for('community.community_mod_list', community_id=community.id) }}">{{ _('Moderators') }}</a>
</div>
</div>
</form>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,48 @@
{% 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">{{ _('Moderators') }}</li>
</ol>
</nav>
<div class="row">
<div class="col col-6">
<h1 class="mt-2">{{ _('Moderators for %(community)s', community=community.display_name()) }}</h1>
</div>
<div class="col col-6 text-right">
<a class="btn btn-primary" href="{{ url_for('community.community_add_moderator', community_id=community.id) }}">{{ _('Add moderator') }}</a>
</div>
</div>
<table class="table table-responsive">
<thead>
<tr>
<th>{{ _('Name') }}</th>
<th>{{ _('Action') }}</th>
</tr>
</thead>
<tbody>
{% for moderator in moderators %}
<tr>
<td>{{ moderator.display_name() }}</td>
<td>{% if not community.is_owner(moderator) %}
<a class="no-underline confirm_first"
href="{{ url_for('community.community_remove_moderator', community_id=community.id, user_id=moderator.id) }}"
rel="nofollow"><span class="fe fe-delete"> {{ _('Remove') }}</span></a>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}

View file

@ -4,10 +4,10 @@ from flask import current_app, json
from app import celery, db from app import celery, db
from app.activitypub.signature import post_request from app.activitypub.signature import post_request
from app.activitypub.util import default_context from app.activitypub.util import default_context, actor_json_to_model
from app.community.util import send_to_remote_instance from app.community.util import send_to_remote_instance
from app.models import User, CommunityMember, Community, Instance, Site, utcnow, ActivityPubLog from app.models import User, CommunityMember, Community, Instance, Site, utcnow, ActivityPubLog, BannedInstances
from app.utils import gibberish, ap_datetime, instance_banned from app.utils import gibberish, ap_datetime, instance_banned, get_request
def purge_user_then_delete(user_id): def purge_user_then_delete(user_id):
@ -113,4 +113,43 @@ def unsubscribe_from_community(community, user):
db.session.commit() db.session.commit()
post_request(community.ap_inbox_url, undo, user.private_key, user.profile_id() + '#main-key') post_request(community.ap_inbox_url, undo, user.private_key, user.profile_id() + '#main-key')
activity.result = 'success' activity.result = 'success'
db.session.commit() db.session.commit()
def search_for_user(address: str):
if '@' in address:
name, server = address.lower().split('@')
else:
name = address
server = ''
if server:
banned = BannedInstances.query.filter_by(domain=server).first()
if banned:
reason = f" Reason: {banned.reason}" if banned.reason is not None else ''
raise Exception(f"{server} is blocked.{reason}")
already_exists = User.query.filter_by(ap_id=address).first()
else:
already_exists = User.query.filter_by(user_name=name).first()
if already_exists:
return already_exists
# Look up the profile address of the user using WebFinger
# todo: try, except block around every get_request
webfinger_data = get_request(f"https://{server}/.well-known/webfinger",
params={'resource': f"acct:{address[1:]}"})
if webfinger_data.status_code == 200:
webfinger_json = webfinger_data.json()
for links in webfinger_json['links']:
if 'rel' in links and links['rel'] == 'self': # this contains the URL of the activitypub profile
type = links['type'] if 'type' in links else 'application/activity+json'
# retrieve the activitypub profile
user_data = get_request(links['href'], headers={'Accept': type})
# to see the structure of the json contained in community_data, do a GET to https://lemmy.world/c/technology with header Accept: application/activity+json
if user_data.status_code == 200:
user_json = user_data.json()
user_data.close()
if user_json['type'] == 'Person':
user = actor_json_to_model(user_json, name, server)
return user
return None

View file

@ -611,6 +611,16 @@ def joined_communities(user_id):
filter(CommunityMember.user_id == user_id).order_by(Community.title).all() filter(CommunityMember.user_id == user_id).order_by(Community.title).all()
@cache.memoize(timeout=300)
def community_moderators(community_id):
return CommunityMember.query.filter((CommunityMember.community_id == community_id) &
(or_(
CommunityMember.is_owner,
CommunityMember.is_moderator
))
).all()
def finalize_user_setup(user, application_required=False): def finalize_user_setup(user, application_required=False):
from app.activitypub.signature import RsaKeys from app.activitypub.signature import RsaKeys
user.verified = True user.verified = True