From c1971b3d8d2ed50b0cb747512060161a73deda2c Mon Sep 17 00:00:00 2001 From: rimu <3310831+rimu@users.noreply.github.com> Date: Sun, 31 Dec 2023 12:09:20 +1300 Subject: [PATCH] administer communities - list and edit --- app/admin/forms.py | 42 +++++++++++- app/admin/routes.py | 80 ++++++++++++++++++++++- app/auth/routes.py | 5 +- app/cli.py | 1 + app/community/forms.py | 4 +- app/community/routes.py | 6 +- app/models.py | 7 ++ app/static/scss/_mixins.scss | 16 +++++ app/static/structure.css | 20 ++++++ app/static/structure.scss | 6 ++ app/static/styles.css | 16 +++++ app/templates/admin/_nav.html | 1 + app/templates/admin/communities.html | 58 ++++++++++++++++ app/templates/admin/edit_community.html | 52 +++++++++++++++ app/templates/index.html | 4 +- app/utils.py | 2 +- migrations/versions/c80716fd7b79_feeds.py | 44 +++++++++++++ 17 files changed, 351 insertions(+), 13 deletions(-) create mode 100644 app/templates/admin/communities.html create mode 100644 app/templates/admin/edit_community.html create mode 100644 migrations/versions/c80716fd7b79_feeds.py diff --git a/app/admin/forms.py b/app/admin/forms.py index e6bb5746..3b7a5308 100644 --- a/app/admin/forms.py +++ b/app/admin/forms.py @@ -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 \ No newline at end of file diff --git a/app/admin/routes.py b/app/admin/routes.py index 78a1cc44..290fb79c 100644 --- a/app/admin/routes.py +++ b/app/admin/routes.py @@ -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//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//delete', methods=['GET']) +@login_required +@permission_required('administer all communities') +def admin_community_delete(community_id): + return '' \ No newline at end of file diff --git a/app/auth/routes.py b/app/auth/routes.py index ccf5a65a..6613092b 100644 --- a/app/auth/routes.py +++ b/app/auth/routes.py @@ -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)) diff --git a/app/cli.py b/app/cli.py index 4c57c68a..cc7aa35d 100644 --- a/app/cli.py +++ b/app/cli.py @@ -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 diff --git a/app/community/forms.py b/app/community/forms.py index a13cd33e..4b5a5998 100644 --- a/app/community/forms.py +++ b/app/community/forms.py @@ -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')) diff --git a/app/community/routes.py b/app/community/routes.py index e94644a3..afd13628 100644 --- a/app/community/routes.py +++ b/app/community/routes.py @@ -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 diff --git a/app/models.py b/app/models.py index d0e90dc0..d8989f06 100644 --- a/app/models.py +++ b/app/models.py @@ -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') diff --git a/app/static/scss/_mixins.scss b/app/static/scss/_mixins.scss index 5238a148..f34df12a 100644 --- a/app/static/scss/_mixins.scss +++ b/app/static/scss/_mixins.scss @@ -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; } \ No newline at end of file diff --git a/app/static/structure.css b/app/static/structure.css index 874a79b2..017e0e5d 100644 --- a/app/static/structure.css +++ b/app/static/structure.css @@ -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 */ diff --git a/app/static/structure.scss b/app/static/structure.scss index 17e465b2..e5f4d634 100644 --- a/app/static/structure.scss +++ b/app/static/structure.scss @@ -407,3 +407,9 @@ nav, etc which are used site-wide */ padding-right: 5px; } } + +fieldset { + legend { + font-weight: bold; + } +} \ No newline at end of file diff --git a/app/static/styles.css b/app/static/styles.css index d9979570..a74ac8e6 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -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 { diff --git a/app/templates/admin/_nav.html b/app/templates/admin/_nav.html index f5f55110..966e5a86 100644 --- a/app/templates/admin/_nav.html +++ b/app/templates/admin/_nav.html @@ -2,6 +2,7 @@ {{ _('Home') }} | {{ _('Site profile') }} | {{ _('Misc settings') }} | + {{ _('Communities') }} | {{ _('Federation') }} | {{ _('Activities') }} diff --git a/app/templates/admin/communities.html b/app/templates/admin/communities.html new file mode 100644 index 00000000..1bc01390 --- /dev/null +++ b/app/templates/admin/communities.html @@ -0,0 +1,58 @@ +{% extends "base.html" %} +{% from 'bootstrap/form.html' import render_form %} + +{% block app_content %} +
+
+ {% include 'admin/_nav.html' %} +
+
+ +
+
+
+ +
+ + + + + + + + + + + + {% for community in communities %} + + + + + + + + + + + {% endfor %} +
NameTitle# PostsHomePopularAllQualityActions
{{ community.name }} + {{ community.display_name() }}{{ community.post_count }}{{ '✓'|safe if community.show_home else '✗'|safe }}{{ '✓'|safe if community.show_popular else '✗'|safe }}{{ '✓'|safe if community.show_all else '✗'|safe }}{{ ' '|safe if community.low_quality else ' ' }}View | + Edit | + Delete +
+ +
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/admin/edit_community.html b/app/templates/admin/edit_community.html new file mode 100644 index 00000000..490e3cc2 --- /dev/null +++ b/app/templates/admin/edit_community.html @@ -0,0 +1,52 @@ +{% extends "base.html" %} +{% from 'bootstrap/form.html' import render_field %} + +{% block app_content %} +
+
+ {% include 'admin/_nav.html' %} +
+
+ +
+ +
+{% endblock %} \ No newline at end of file diff --git a/app/templates/index.html b/app/templates/index.html index 573d8fb0..e3753136 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -44,7 +44,9 @@ diff --git a/app/utils.py b/app/utils.py index 415fa26f..fd8e422c 100644 --- a/app/utils.py +++ b/app/utils.py @@ -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] diff --git a/migrations/versions/c80716fd7b79_feeds.py b/migrations/versions/c80716fd7b79_feeds.py new file mode 100644 index 00000000..320d6fe5 --- /dev/null +++ b/migrations/versions/c80716fd7b79_feeds.py @@ -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 ###