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'))
allow_local_image_posts = BooleanField(_l('Allow local image posts'))
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'))
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'))
@ -37,3 +37,43 @@ class FederationForm(FlaskForm):
use_blocklist = BooleanField(_l('Blocklist instead of allowlist'))
blocklist = TextAreaField(_l('Deny federation with these instances'))
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 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_babel import _
from sqlalchemy import text, desc
from app import db
from app.activitypub.routes import process_inbox_request, process_delete_request
from app.admin.forms import FederationForm, SiteMiscForm, SiteProfileForm
from app.models import AllowedInstances, BannedInstances, ActivityPubLog, utcnow, Site
from app.admin.forms import FederationForm, SiteMiscForm, SiteProfileForm, EditCommunityForm
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.admin import bp
@ -158,3 +159,76 @@ def activity_replay(activity_id):
else:
process_inbox_request(request_json, activity.id)
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 flask_login import login_user, logout_user, current_user
from flask_babel import _
from app import db
from app import db, cache
from app.auth import bp
from app.auth.forms import LoginForm, RegistrationForm, ResetPasswordRequestForm, ResetPasswordForm
from app.auth.util import random_token
from app.models import User, utcnow, IpBan
from app.auth.email import send_password_reset_email, send_welcome_email, send_verification_email
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'])
@ -50,6 +50,7 @@ def login():
new_ip_ban = IpBan(ip_address=ip_address(), notes=user.user_name + ' used new IP address')
db.session.add(new_ip_ban)
db.session.commit()
cache.delete_memoized('banned_ip_addresses')
# 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))

View file

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

View file

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

View file

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

View file

@ -89,6 +89,7 @@ class Community(db.Model):
last_active = db.Column(db.DateTime, default=utcnow)
public_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_profile_id = db.Column(db.String(255), index=True)
@ -108,6 +109,11 @@ class Community(db.Model):
searchable = db.Column(db.Boolean, default=True)
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'))
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
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
ip_address = db.Column(db.String(50))
posts = db.relationship('Post', backref='instance', lazy='dynamic')
post_replies = db.relationship('PostReply', backref='instance', lazy='dynamic')

View file

@ -21,4 +21,20 @@
.pl-0 {
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;
}
.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/ */
/* use https://fontdrop.info/ to get the unicode values of the featuer.ttf file */
@font-face {
@ -684,4 +700,8 @@ fieldset legend {
padding-right: 5px;
}
fieldset legend {
font-weight: bold;
}
/*# sourceMappingURL=structure.css.map */

View file

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

View file

@ -11,6 +11,22 @@
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/ */
/* use https://fontdrop.info/ to get the unicode values of the featuer.ttf file */
@font-face {

View file

@ -2,6 +2,7 @@
<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_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_activities') }}">{{ _('Activities') }}</a>
</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">
{% for community in active_communities %}
<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>
{% endfor %}
</ul>

View file

@ -365,7 +365,7 @@ def user_cookie_banned() -> bool:
return cookie is not None
@cache.cached(timeout=300)
@cache.memoize(timeout=300)
def banned_ip_addresses() -> List[str]:
ips = IpBan.query.all()
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 ###