administer communities - list and edit

This commit is contained in:
rimu 2023-12-31 12:09:20 +13:00
parent ede9c32953
commit c1971b3d8d
17 changed files with 351 additions and 13 deletions

View file

@ -21,7 +21,7 @@ class SiteMiscForm(FlaskForm):
enable_downvotes = BooleanField(_l('Enable downvotes')) enable_downvotes = BooleanField(_l('Enable downvotes'))
allow_local_image_posts = BooleanField(_l('Allow local image posts')) allow_local_image_posts = BooleanField(_l('Allow local image posts'))
remote_image_cache_days = IntegerField(_l('Days to cache images from remote instances for')) remote_image_cache_days = IntegerField(_l('Days to cache images from remote instances for'))
enable_nsfw = BooleanField(_l('Allow NSFW communities and posts')) enable_nsfw = BooleanField(_l('Allow NSFW communities'))
enable_nsfl = BooleanField(_l('Allow NSFL communities and posts')) enable_nsfl = BooleanField(_l('Allow NSFL communities and posts'))
community_creation_admin_only = BooleanField(_l('Only admins can create new local communities')) community_creation_admin_only = BooleanField(_l('Only admins can create new local communities'))
reports_email_admins = BooleanField(_l('Notify admins about reports, not just moderators')) reports_email_admins = BooleanField(_l('Notify admins about reports, not just moderators'))
@ -37,3 +37,43 @@ class FederationForm(FlaskForm):
use_blocklist = BooleanField(_l('Blocklist instead of allowlist')) use_blocklist = BooleanField(_l('Blocklist instead of allowlist'))
blocklist = TextAreaField(_l('Deny federation with these instances')) blocklist = TextAreaField(_l('Deny federation with these instances'))
submit = SubmitField(_l('Save')) submit = SubmitField(_l('Save'))
class EditCommunityForm(FlaskForm):
title = StringField(_l('Title'), validators=[DataRequired()])
url = StringField(_l('Url'), validators=[DataRequired()])
description = TextAreaField(_l('Description'))
icon_file = FileField(_('Icon image'))
banner_file = FileField(_('Banner image'))
rules = TextAreaField(_l('Rules'))
nsfw = BooleanField('Porn community')
show_home = BooleanField('Posts show on home page')
show_popular = BooleanField('Posts can be popular')
show_all = BooleanField('Posts show in All list')
low_quality = BooleanField("Low quality / toxic - upvotes in here don't add to reputation")
options = [(-1, _l('Forever')),
(7, _l('1 week')),
(14, _l('2 weeks')),
(28, _l('1 month')),
(56, _l('2 months')),
(84, _l('3 months')),
(168, _l('6 months')),
(365, _l('1 year')),
(730, _l('2 years')),
(1825, _l('5 years')),
(3650, _l('10 years')),
]
content_retention = SelectField(_l('Retain content'), choices=options, default=1, coerce=int)
submit = SubmitField(_l('Save'))
def validate(self, extra_validators=None):
if not super().validate():
return False
if self.url.data.strip() == '':
self.url.errors.append(_('Url is required.'))
return False
else:
if '-' in self.url.data.strip():
self.url.errors.append(_('- cannot be in Url. Use _ instead?'))
return False
return True

View file

@ -1,14 +1,15 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from flask import request, flash, json, url_for, current_app from flask import request, flash, json, url_for, current_app, redirect
from flask_login import login_required, current_user from flask_login import login_required, current_user
from flask_babel import _ from flask_babel import _
from sqlalchemy import text, desc from sqlalchemy import text, desc
from app import db from app import db
from app.activitypub.routes import process_inbox_request, process_delete_request from app.activitypub.routes import process_inbox_request, process_delete_request
from app.admin.forms import FederationForm, SiteMiscForm, SiteProfileForm from app.admin.forms import FederationForm, SiteMiscForm, SiteProfileForm, EditCommunityForm
from app.models import AllowedInstances, BannedInstances, ActivityPubLog, utcnow, Site from app.community.util import save_icon_file, save_banner_file
from app.models import AllowedInstances, BannedInstances, ActivityPubLog, utcnow, Site, Community
from app.utils import render_template, permission_required, set_setting, get_setting from app.utils import render_template, permission_required, set_setting, get_setting
from app.admin import bp from app.admin import bp
@ -158,3 +159,76 @@ def activity_replay(activity_id):
else: else:
process_inbox_request(request_json, activity.id) process_inbox_request(request_json, activity.id)
return 'Ok' return 'Ok'
@bp.route('/communities', methods=['GET'])
@login_required
@permission_required('administer all communities')
def admin_communities():
page = request.args.get('page', 1, type=int)
communities = Community.query.order_by(Community.title).paginate(page=page, per_page=1000, error_out=False)
next_url = url_for('admin.admin_communities', page=communities.next_num) if communities.has_next else None
prev_url = url_for('admin.admin_communities', page=communities.prev_num) if communities.has_prev and page != 1 else None
return render_template('admin/communities.html', title=_('Communities'), next_url=next_url, prev_url=prev_url,
communities=communities)
@bp.route('/community/<int:community_id>/edit', methods=['GET', 'POST'])
@login_required
@permission_required('administer all communities')
def admin_community_edit(community_id):
form = EditCommunityForm()
community = Community.query.get_or_404(community_id)
if form.validate_on_submit():
community.name = form.url.data
community.title = form.title.data
community.description = form.description.data
community.rules = form.rules.data
community.nsfw = form.nsfw.data
community.show_home = form.show_home.data
community.show_popular = form.show_popular.data
community.show_all = form.show_all.data
community.low_quality = form.low_quality.data
community.content_retention = form.content_retention.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()
flash(_('Saved'))
return redirect(url_for('admin.admin_communities'))
else:
if not community.is_local():
flash(_('This is a remote community - most settings here will be regularly overwritten with data from the original server.'), 'warning')
form.url.data = community.name
form.title.data = community.title
form.description.data = community.description
form.rules.data = community.rules
form.nsfw.data = community.nsfw
form.show_home.data = community.show_home
form.show_popular.data = community.show_popular
form.show_all.data = community.show_all
form.low_quality.data = community.low_quality
form.content_retention.data = community.content_retention
return render_template('admin/edit_community.html', title=_('Edit community'), form=form, community=community)
@bp.route('/community/<int:community_id>/delete', methods=['GET'])
@login_required
@permission_required('administer all communities')
def admin_community_delete(community_id):
return ''

View file

@ -3,14 +3,14 @@ from flask import redirect, url_for, flash, request, make_response, session, Mar
from werkzeug.urls import url_parse from werkzeug.urls import url_parse
from flask_login import login_user, logout_user, current_user from flask_login import login_user, logout_user, current_user
from flask_babel import _ from flask_babel import _
from app import db from app import db, cache
from app.auth import bp from app.auth import bp
from app.auth.forms import LoginForm, RegistrationForm, ResetPasswordRequestForm, ResetPasswordForm from app.auth.forms import LoginForm, RegistrationForm, ResetPasswordRequestForm, ResetPasswordForm
from app.auth.util import random_token from app.auth.util import random_token
from app.models import User, utcnow, IpBan from app.models import User, utcnow, IpBan
from app.auth.email import send_password_reset_email, send_welcome_email, send_verification_email from app.auth.email import send_password_reset_email, send_welcome_email, send_verification_email
from app.activitypub.signature import RsaKeys from app.activitypub.signature import RsaKeys
from app.utils import render_template, ip_address, user_ip_banned, user_cookie_banned from app.utils import render_template, ip_address, user_ip_banned, user_cookie_banned, banned_ip_addresses
@bp.route('/login', methods=['GET', 'POST']) @bp.route('/login', methods=['GET', 'POST'])
@ -50,6 +50,7 @@ def login():
new_ip_ban = IpBan(ip_address=ip_address(), notes=user.user_name + ' used new IP address') new_ip_ban = IpBan(ip_address=ip_address(), notes=user.user_name + ' used new IP address')
db.session.add(new_ip_ban) db.session.add(new_ip_ban)
db.session.commit() db.session.commit()
cache.delete_memoized('banned_ip_addresses')
# Set a cookie so we have another way to track banned people # Set a cookie so we have another way to track banned people
response.set_cookie('sesion', '17489047567495', expires=datetime(year=2099, month=12, day=30)) response.set_cookie('sesion', '17489047567495', expires=datetime(year=2099, month=12, day=30))

View file

@ -110,6 +110,7 @@ def register(app):
admin_role.permissions.append(RolePermission(permission='ban users')) admin_role.permissions.append(RolePermission(permission='ban users'))
admin_role.permissions.append(RolePermission(permission='manage users')) admin_role.permissions.append(RolePermission(permission='manage users'))
admin_role.permissions.append(RolePermission(permission='change instance settings')) admin_role.permissions.append(RolePermission(permission='change instance settings'))
admin_role.permissions.append(RolePermission(permission='administer all communities'))
db.session.add(admin_role) db.session.add(admin_role)
# Admin user # Admin user

View file

@ -13,7 +13,7 @@ class AddLocalCommunity(FlaskForm):
icon_file = FileField(_('Icon image')) icon_file = FileField(_('Icon image'))
banner_file = FileField(_('Banner image')) banner_file = FileField(_('Banner image'))
rules = TextAreaField(_l('Rules')) rules = TextAreaField(_l('Rules'))
nsfw = BooleanField('18+ NSFW') nsfw = BooleanField('NSFW')
submit = SubmitField(_l('Create')) submit = SubmitField(_l('Create'))
def validate(self, extra_validators=None): def validate(self, extra_validators=None):
@ -45,7 +45,7 @@ class CreatePostForm(FlaskForm):
image_file = FileField(_('Image')) image_file = FileField(_('Image'))
# flair = SelectField(_l('Flair'), coerce=int) # flair = SelectField(_l('Flair'), coerce=int)
nsfw = BooleanField(_l('NSFW')) nsfw = BooleanField(_l('NSFW'))
nsfl = BooleanField(_l('NSFL')) nsfl = BooleanField(_l('Content warning'))
notify_author = BooleanField(_l('Notify about replies')) notify_author = BooleanField(_l('Notify about replies'))
submit = SubmitField(_l('Save')) submit = SubmitField(_l('Save'))

View file

@ -26,7 +26,7 @@ from datetime import timezone
@login_required @login_required
def add_local(): def add_local():
form = AddLocalCommunity() form = AddLocalCommunity()
if get_setting('allow_nsfw', False) is False: if g.site.enable_nsfw is False:
form.nsfw.render_kw = {'disabled': True} form.nsfw.render_kw = {'disabled': True}
if form.validate_on_submit() and not community_url_exists(form.url.data): if form.validate_on_submit() and not community_url_exists(form.url.data):
@ -289,9 +289,9 @@ def unsubscribe(actor):
def add_post(actor): def add_post(actor):
community = actor_to_community(actor) community = actor_to_community(actor)
form = CreatePostForm() form = CreatePostForm()
if get_setting('allow_nsfw', False) is False: if g.site.enable_nsfw is False:
form.nsfw.render_kw = {'disabled': True} form.nsfw.render_kw = {'disabled': True}
if get_setting('allow_nsfl', False) is False: if g.site.enable_nsfl is False:
form.nsfl.render_kw = {'disabled': True} form.nsfl.render_kw = {'disabled': True}
if community.nsfw: if community.nsfw:
form.nsfw.data = True form.nsfw.data = True

View file

@ -89,6 +89,7 @@ class Community(db.Model):
last_active = db.Column(db.DateTime, default=utcnow) last_active = db.Column(db.DateTime, default=utcnow)
public_key = db.Column(db.Text) public_key = db.Column(db.Text)
private_key = db.Column(db.Text) private_key = db.Column(db.Text)
content_retention = db.Column(db.Integer, default=-1)
ap_id = db.Column(db.String(255), index=True) ap_id = db.Column(db.String(255), index=True)
ap_profile_id = db.Column(db.String(255), index=True) ap_profile_id = db.Column(db.String(255), index=True)
@ -108,6 +109,11 @@ class Community(db.Model):
searchable = db.Column(db.Boolean, default=True) searchable = db.Column(db.Boolean, default=True)
private_mods = db.Column(db.Boolean, default=False) private_mods = db.Column(db.Boolean, default=False)
# Which feeds posts from this community show up in
show_home = db.Column(db.Boolean, default=False) # For anonymous users. When logged in, the home feed shows posts from subscribed communities
show_popular = db.Column(db.Boolean, default=True)
show_all = db.Column(db.Boolean, default=True)
search_vector = db.Column(TSVectorType('name', 'title', 'description', 'rules')) search_vector = db.Column(TSVectorType('name', 'title', 'description', 'rules'))
posts = db.relationship('Post', backref='community', lazy='dynamic', cascade="all, delete-orphan") posts = db.relationship('Post', backref='community', lazy='dynamic', cascade="all, delete-orphan")
@ -749,6 +755,7 @@ class Instance(db.Model):
dormant = db.Column(db.Boolean, default=False) # True once this instance is considered offline and not worth sending to any more dormant = db.Column(db.Boolean, default=False) # True once this instance is considered offline and not worth sending to any more
start_trying_again = db.Column(db.DateTime) # When to start trying again. Should grow exponentially with each failure. start_trying_again = db.Column(db.DateTime) # When to start trying again. Should grow exponentially with each failure.
gone_forever = db.Column(db.Boolean, default=False) # True once this instance is considered offline forever - never start trying again gone_forever = db.Column(db.Boolean, default=False) # True once this instance is considered offline forever - never start trying again
ip_address = db.Column(db.String(50))
posts = db.relationship('Post', backref='instance', lazy='dynamic') posts = db.relationship('Post', backref='instance', lazy='dynamic')
post_replies = db.relationship('PostReply', backref='instance', lazy='dynamic') post_replies = db.relationship('PostReply', backref='instance', lazy='dynamic')

View file

@ -22,3 +22,19 @@
.pl-0 { .pl-0 {
padding-left: 0!important; padding-left: 0!important;
} }
.pl-1 {
padding-left: 5px!important;
}
.pl-2 {
padding-left: 10px!important;
}
.pl-3 {
padding-left: 15px!important;
}
.pl-4 {
padding-left: 20px!important;
}

View file

@ -12,6 +12,22 @@ nav, etc which are used site-wide */
padding-left: 0 !important; padding-left: 0 !important;
} }
.pl-1 {
padding-left: 5px !important;
}
.pl-2 {
padding-left: 10px !important;
}
.pl-3 {
padding-left: 15px !important;
}
.pl-4 {
padding-left: 20px !important;
}
/* for more info about the feather font used for icons see https://at-ui.github.io/feather-font/ */ /* for more info about the feather font used for icons see https://at-ui.github.io/feather-font/ */
/* use https://fontdrop.info/ to get the unicode values of the featuer.ttf file */ /* use https://fontdrop.info/ to get the unicode values of the featuer.ttf file */
@font-face { @font-face {
@ -684,4 +700,8 @@ fieldset legend {
padding-right: 5px; padding-right: 5px;
} }
fieldset legend {
font-weight: bold;
}
/*# sourceMappingURL=structure.css.map */ /*# sourceMappingURL=structure.css.map */

View file

@ -407,3 +407,9 @@ nav, etc which are used site-wide */
padding-right: 5px; padding-right: 5px;
} }
} }
fieldset {
legend {
font-weight: bold;
}
}

View file

@ -11,6 +11,22 @@
padding-left: 0 !important; padding-left: 0 !important;
} }
.pl-1 {
padding-left: 5px !important;
}
.pl-2 {
padding-left: 10px !important;
}
.pl-3 {
padding-left: 15px !important;
}
.pl-4 {
padding-left: 20px !important;
}
/* for more info about the feather font used for icons see https://at-ui.github.io/feather-font/ */ /* for more info about the feather font used for icons see https://at-ui.github.io/feather-font/ */
/* use https://fontdrop.info/ to get the unicode values of the featuer.ttf file */ /* use https://fontdrop.info/ to get the unicode values of the featuer.ttf file */
@font-face { @font-face {

View file

@ -2,6 +2,7 @@
<a href="{{ url_for('admin.admin_home') }}">{{ _('Home') }}</a> | <a href="{{ url_for('admin.admin_home') }}">{{ _('Home') }}</a> |
<a href="{{ url_for('admin.admin_site') }}">{{ _('Site profile') }}</a> | <a href="{{ url_for('admin.admin_site') }}">{{ _('Site profile') }}</a> |
<a href="{{ url_for('admin.admin_misc') }}">{{ _('Misc settings') }}</a> | <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_federation') }}">{{ _('Federation') }}</a> | <a href="{{ url_for('admin.admin_federation') }}">{{ _('Federation') }}</a> |
<a href="{{ url_for('admin.admin_activities') }}">{{ _('Activities') }}</a> <a href="{{ url_for('admin.admin_activities') }}">{{ _('Activities') }}</a>
</nav> </nav>

View file

@ -0,0 +1,58 @@
{% 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"> <input type="submit" name="submit" value="Search">
</form>
<table class="table table-striped">
<tr>
<th>Name</th>
<th>Title</th>
<th># Posts</th>
<th>Home</th>
<th>Popular</th>
<th>All</th>
<th>Quality</th>
<th>Actions</th>
</tr>
{% for community in communities %}
<tr>
<td>{{ community.name }}</td>
<td><img src="{{ community.icon_image('tiny') }}" class="community_icon rounded-circle" loading="lazy" />
{{ community.display_name() }}</td>
<td>{{ community.post_count }}</td>
<th>{{ '&check;'|safe if community.show_home else '&cross;'|safe }}</th>
<th>{{ '&check;'|safe if community.show_popular else '&cross;'|safe }}</th>
<th>{{ '&check;'|safe if community.show_all else '&cross;'|safe }}</th>
<th>{{ '<span class="fe fe-arrow-down"> </span>'|safe if community.low_quality else ' ' }}</th>
<td><a href="/c/{{ community.link() }}">View</a> |
<a href="{{ url_for('admin.admin_community_edit', community_id=community.id) }}">Edit</a> |
<a href="{{ url_for('admin.admin_community_delete', community_id=community.id) }}" class="confirm_first">Delete</a>
</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

@ -0,0 +1,52 @@
{% extends "base.html" %}
{% from 'bootstrap/form.html' import render_field %}
{% block app_content %}
<div class="row">
<div class="col">
{% include 'admin/_nav.html' %}
</div>
</div>
<div class="row">
<div class="col col-login mx-auto">
<h3>{{ _('Edit %(community_name)s', community_name=community.display_name()) }}</h3>
<form method="post" enctype="multipart/form-data" id="add_local_community_form">
{{ form.csrf_token() }}
{{ render_field(form.title) }}
<div class="form-group">{{ form.url.label(class_="form-control-label required") }}
/c/{{ form.url(class_="form-control", maxlength=255) }}
{% for error in form.url.errors %}
<span style="color: red;">[{{ error }}]</span>
{% endfor %}
</div>
{{ 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) }}
{% if not community.is_local() %}
<fieldset class="border pl-2 pt-2 mb-4">
<legend>{{ _('Will not be overwritten by remote server') }}</legend>
{% endif %}
{{ render_field(form.show_home) }}
{{ render_field(form.show_popular) }}
{{ render_field(form.show_all) }}
{{ render_field(form.low_quality) }}
{{ render_field(form.content_retention) }}
{% if not community.is_local() %}
</fieldset>
{% endif %}
{{ render_field(form.submit) }}
</form>
</div>
</div>
{% endblock %}

View file

@ -44,7 +44,9 @@
<ul class="list-group list-group-flush"> <ul class="list-group list-group-flush">
{% for community in active_communities %} {% for community in active_communities %}
<li class="list-group-item"> <li class="list-group-item">
<a href="/c/{{ community.link() }}"><img src="{{ community.icon_image() }}" class="community_icon rounded-circle" loading="lazy" />{{ community.display_name() }}</a> <a href="/c/{{ community.link() }}"><img src="{{ community.icon_image() }}" class="community_icon rounded-circle" loading="lazy" />
{{ community.display_name() }}
</a>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>

View file

@ -365,7 +365,7 @@ def user_cookie_banned() -> bool:
return cookie is not None return cookie is not None
@cache.cached(timeout=300) @cache.memoize(timeout=300)
def banned_ip_addresses() -> List[str]: def banned_ip_addresses() -> List[str]:
ips = IpBan.query.all() ips = IpBan.query.all()
return [ip.ip_address for ip in ips] return [ip.ip_address for ip in ips]

View file

@ -0,0 +1,44 @@
"""feeds
Revision ID: c80716fd7b79
Revises: 3f17b9ab55e4
Create Date: 2023-12-31 12:05:39.109343
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'c80716fd7b79'
down_revision = '3f17b9ab55e4'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('community', schema=None) as batch_op:
batch_op.add_column(sa.Column('content_retention', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('show_home', sa.Boolean(), nullable=True))
batch_op.add_column(sa.Column('show_popular', sa.Boolean(), nullable=True))
batch_op.add_column(sa.Column('show_all', sa.Boolean(), nullable=True))
with op.batch_alter_table('instance', schema=None) as batch_op:
batch_op.add_column(sa.Column('ip_address', sa.String(length=50), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('instance', schema=None) as batch_op:
batch_op.drop_column('ip_address')
with op.batch_alter_table('community', schema=None) as batch_op:
batch_op.drop_column('show_all')
batch_op.drop_column('show_popular')
batch_op.drop_column('show_home')
batch_op.drop_column('content_retention')
# ### end Alembic commands ###