From 8c3c46271d0b21495907b804a6cd6d77d27bb1dd Mon Sep 17 00:00:00 2001 From: rimu <3310831+rimu@users.noreply.github.com> Date: Sun, 17 Sep 2023 21:19:51 +1200 Subject: [PATCH] UI to create posts --- CONTRIBUTING.md | 4 + app/__init__.py | 2 +- app/activitypub/routes.py | 1 + app/activitypub/util.py | 2 +- app/cli.py | 6 ++ app/community/forms.py | 58 +++++++++++++- app/community/routes.py | 77 ++++++++++++++----- app/community/util.py | 11 ++- app/constants.py | 1 + app/main/routes.py | 2 +- app/models.py | 17 ++++ app/sorting.py | 50 ++++++++++++ app/static/js/scripts.js | 30 +++++++- app/static/styles.css | 8 ++ app/static/styles.scss | 8 ++ app/templates/community/add_post.html | 69 +++++++++++++++++ app/templates/community/community.html | 10 ++- app/utils.py | 5 +- .../f8200275644a_community_ban_field.py | 48 ++++++++++++ pyfedi.py | 2 +- 20 files changed, 376 insertions(+), 35 deletions(-) create mode 100644 app/sorting.py create mode 100644 app/templates/community/add_post.html create mode 100644 migrations/versions/f8200275644a_community_ban_field.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6fba7d2d..c6955b98 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,4 +3,8 @@ Please discuss your ideas in an issue at https://codeberg.org/rimu/pyfedi/issues before starting any large pieces of work to ensure alignment with the roadmap, architecture and processes. +The general style and philosphy behind the way things have been constructed is well described by +[The Grug Brained Developer](https://grugbrain.dev/). If that page resonates with you then you'll +probably enjoy your time here! + Mailing list, Matrix channel, etc still to come. \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py index 0bc53e29..864c3c3b 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -27,7 +27,7 @@ mail = Mail() bootstrap = Bootstrap5() moment = Moment() babel = Babel() -cache = Cache(config={'CACHE_TYPE': os.environ.get('CACHE_TYPE'), 'CACHE_DIR': os.environ.get('CACHE_DIR') or '/dev/shm'}) +cache = Cache() def create_app(config_class=Config): diff --git a/app/activitypub/routes.py b/app/activitypub/routes.py index 0bfa0925..87a65f73 100644 --- a/app/activitypub/routes.py +++ b/app/activitypub/routes.py @@ -338,6 +338,7 @@ def shared_inbox(): activity_log.exception_message = 'Could not detect type of like' if activity_log.result == 'success': ... # todo: recalculate 'hotness' of liked post/reply + # todo: if vote was on content in local community, federate the vote out to followers # Follow: remote user wants to follow one of our communities elif request_json['type'] == 'Follow': # Follow is when someone wants to join a community diff --git a/app/activitypub/util.py b/app/activitypub/util.py index 937a38d3..07a598ae 100644 --- a/app/activitypub/util.py +++ b/app/activitypub/util.py @@ -176,7 +176,7 @@ def banned_user_agents(): return [] # todo: finish this function -@cache.cached(150) +@cache.memoize(150) def instance_blocked(host: str) -> bool: host = host.lower() if 'https://' in host or 'http://' in host: diff --git a/app/cli.py b/app/cli.py index 8a495f4e..a966d217 100644 --- a/app/cli.py +++ b/app/cli.py @@ -49,7 +49,13 @@ def register(app): db.configure_mappers() db.create_all() db.session.append(Settings(name='allow_nsfw', value=json.dumps(False))) + db.session.append(Settings(name='allow_nsfl', value=json.dumps(False))) db.session.append(Settings(name='allow_dislike', value=json.dumps(True))) + db.session.append(Settings(name='allow_local_image_posts', value=json.dumps(True))) + db.session.append(Settings(name='allow_remote_image_posts', value=json.dumps(True))) + db.session.append(Settings(name='registration_open', value=json.dumps(True))) + db.session.append(Settings(name='approve_registrations', value=json.dumps(False))) + db.session.append(Settings(name='federation', value=json.dumps(True))) db.session.append(BannedInstances(domain='lemmygrad.ml')) db.session.append(BannedInstances(domain='gab.com')) db.session.append(BannedInstances(domain='exploding-heads.com')) diff --git a/app/community/forms.py b/app/community/forms.py index 434395c2..39c8a79e 100644 --- a/app/community/forms.py +++ b/app/community/forms.py @@ -1,8 +1,10 @@ from flask_wtf import FlaskForm -from wtforms import StringField, SubmitField, TextAreaField, BooleanField -from wtforms.validators import ValidationError, DataRequired, Email, EqualTo, Length +from wtforms import StringField, SubmitField, TextAreaField, BooleanField, HiddenField, SelectField, FileField +from wtforms.validators import ValidationError, DataRequired, Email, EqualTo, Length, Optional from flask_babel import _, lazy_gettext as _l +from app.utils import domain_from_url + class AddLocalCommunity(FlaskForm): community_name = StringField(_l('Name'), validators=[DataRequired()]) @@ -15,4 +17,54 @@ class AddLocalCommunity(FlaskForm): class SearchRemoteCommunity(FlaskForm): address = StringField(_l('Server address'), validators=[DataRequired()]) - submit = SubmitField(_l('Search')) \ No newline at end of file + submit = SubmitField(_l('Search')) + + +class CreatePost(FlaskForm): + communities = SelectField(_l('Community'), validators=[DataRequired()], coerce=int) + type = HiddenField() # https://getbootstrap.com/docs/4.6/components/navs/#tabs + discussion_title = StringField(_l('Title'), validators={Optional(), Length(min=3, max=255)}) + discussion_body = TextAreaField(_l('Body'), render_kw={'placeholder': 'Text (optional)'}) + link_title = StringField(_l('Title'), validators={Optional(), Length(min=3, max=255)}) + link_url = StringField(_l('URL'), render_kw={'placeholder': 'https://...'}) + image_title = StringField(_l('Title'), validators={Optional(), Length(min=3, max=255)}) + image_file = FileField(_('Image')) + # flair = SelectField(_l('Flair'), coerce=int) + nsfw = BooleanField(_l('NSFW')) + nsfl = BooleanField(_l('NSFL')) + notify = BooleanField(_l('Send me post reply notifications')) + submit = SubmitField(_l('Post')) + + def validate(self, extra_validators=None) -> bool: + if not super().validate(): + return False + if self.type.data is None or self.type.data == '': + self.type.data = 'discussion' + + if self.type.data == 'discussion': + if self.discussion_title.data == '': + self.discussion_title.errors.append(_('Title is required.')) + return False + elif self.type.data == 'link': + if self.link_title.data == '': + self.link_title.errors.append(_('Title is required.')) + return False + if self.link_url.data == '': + self.link_url.errors.append(_('URL is required.')) + return False + domain = domain_from_url(self.link_url.data) + if domain.banned: + self.link_url.errors.append(_(f"Links to %s are not allowed.".format(domain.name))) + return False + elif self.type.data == 'image': + if self.image_title.data == '': + self.image_title.errors.append(_('Title is required.')) + return False + if self.image_file.data == '': + self.image_file.errors.append(_('File is required.')) + return False + elif self.type.data == 'poll': + self.discussion_title.errors.append(_('Poll not implemented yet.')) + return False + + return True diff --git a/app/community/routes.py b/app/community/routes.py index 19a56d2a..8f0fb86a 100644 --- a/app/community/routes.py +++ b/app/community/routes.py @@ -1,16 +1,19 @@ from datetime import date, datetime, timedelta + +import markdown2 from flask import redirect, url_for, flash, request, make_response, session, Markup, current_app, abort from flask_login import login_user, logout_user, current_user from flask_babel import _ +from sqlalchemy import or_ + from app import db from app.activitypub.signature import RsaKeys, HttpSignature -from app.community.forms import SearchRemoteCommunity, AddLocalCommunity -from app.community.util import search_for_community, community_url_exists -from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER -from app.models import User, Community, CommunityMember, CommunityJoinRequest, CommunityBan +from app.community.forms import SearchRemoteCommunity, AddLocalCommunity, CreatePost +from app.community.util import search_for_community, community_url_exists, actor_to_community +from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, POST_TYPE_LINK, POST_TYPE_ARTICLE, POST_TYPE_IMAGE +from app.models import User, Community, CommunityMember, CommunityJoinRequest, CommunityBan, Post from app.community import bp -from app.utils import get_setting, render_template -from sqlalchemy import or_ +from app.utils import get_setting, render_template, allowlist_html @bp.route('/add_local', methods=['GET', 'POST']) @@ -21,7 +24,7 @@ def add_local(): if form.validate_on_submit() and not community_url_exists(form.url.data): # todo: more intense data validation - if form.url.data.trim().lower().startswith('/c/'): + if form.url.data.strip().lower().startswith('/c/'): form.url.data = form.url.data[3:] private_key, public_key = RsaKeys.generate_keypair() community = Community(title=form.community_name.data, name=form.url.data, description=form.description.data, @@ -65,12 +68,7 @@ def add_remote(): # @bp.route('/c/', methods=['GET']) - defined in activitypub/routes.py, which calls this function for user requests. A bit weird. def show_community(community: Community): - mods = CommunityMember.query.filter((CommunityMember.community_id == community.id) & - (or_( - CommunityMember.is_owner, - CommunityMember.is_moderator - )) - ).all() + mods = community.moderators() is_moderator = any(mod.user_id == current_user.id for mod in mods) is_owner = any(mod.user_id == current_user.id and mod.is_owner == True for mod in mods) @@ -107,7 +105,7 @@ def subscribe(actor): "to": [community.ap_id], "object": community.ap_id, "type": "Follow", - "id": f"https://{current_app.config['SERVER_NAME']}/activities/follow/" + join_request.id + "id": f"https://{current_app.config['SERVER_NAME']}/activities/follow/{join_request.id}" } try: message = HttpSignature.signed_request(community.ap_inbox_url, follow, current_user.private_key, @@ -140,11 +138,7 @@ def subscribe(actor): @bp.route('//unsubscribe', methods=['GET']) def unsubscribe(actor): - actor = actor.strip() - if '@' in actor: - community = Community.query.filter_by(banned=False, ap_id=actor).first() - else: - community = Community.query.filter_by(name=actor, banned=False, ap_id=None).first() + community = actor_to_community(actor) if community is not None: subscription = current_user.subscribed(community) @@ -165,3 +159,48 @@ def unsubscribe(actor): return redirect('/c/' + actor) else: abort(404) + + +@bp.route('//submit', methods=['GET', 'POST']) +def add_post(actor): + community = actor_to_community(actor) + form = CreatePost() + if get_setting('allow_nsfw', False) is False: + form.nsfw.render_kw = {'disabled': True} + if get_setting('allow_nsfl', False) is False: + form.nsfl.render_kw = {'disabled': True} + images_disabled = 'disabled' if not get_setting('allow_local_image_posts', True) else '' + + form.communities.choices = [(c.id, c.display_name()) for c in current_user.communities()] + + if form.validate_on_submit(): + post = Post(user_id=current_user.id, community_id=form.communities.data, nsfw=form.nsfw.data, + nsfl=form.nsfl.data) + if form.type.data == '' or form.type.data == 'discussion': + post.title = form.discussion_title.data + post.body = form.discussion_body.data + post.body_html = allowlist_html(markdown2.markdown(post.body, safe_mode=True)) + post.type = POST_TYPE_ARTICLE + elif form.type.data == 'link': + post.title = form.link_title.data + post.url = form.link_url.data + post.type = POST_TYPE_LINK + elif form.type.data == 'image': + post.title = form.image_title.data + post.type = POST_TYPE_IMAGE + # todo: handle file upload + elif form.type.data == 'poll': + ... + else: + raise Exception('invalid post type') + db.session.add(post) + db.session.commit() + + flash('Post has been added') + return redirect(f"/c/{community.link()}") + else: + form.communities.data = community.id + form.notify.data = True + + return render_template('community/add_post.html', title=_('Add post to community'), form=form, community=community, + images_disabled=images_disabled) diff --git a/app/community/util.py b/app/community/util.py index e5ffcac2..4c70944b 100644 --- a/app/community/util.py +++ b/app/community/util.py @@ -67,5 +67,14 @@ def search_for_community(address: str): def community_url_exists(url) -> bool: - community = Community.query.filter_by(url=url).first() + community = Community.query.filter_by(ap_profile_id=url).first() return community is not None + + +def actor_to_community(actor) -> Community: + actor = actor.strip() + if '@' in actor: + community = Community.query.filter_by(banned=False, ap_id=actor).first() + else: + community = Community.query.filter_by(name=actor, banned=False, ap_id=None).first() + return community diff --git a/app/constants.py b/app/constants.py index d4a274f6..1074dd06 100644 --- a/app/constants.py +++ b/app/constants.py @@ -4,6 +4,7 @@ POST_TYPE_LINK = 1 POST_TYPE_ARTICLE = 2 POST_TYPE_IMAGE = 3 POST_TYPE_VIDEO = 4 +POST_TYPE_POLL = 5 DATETIME_MS_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ" diff --git a/app/main/routes.py b/app/main/routes.py index c5588f49..41154428 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -8,7 +8,7 @@ from flask_login import current_user from flask_babel import _, get_locale from sqlalchemy import select from sqlalchemy_searchable import search -from app.utils import render_template +from app.utils import render_template, get_setting from app.models import Community, CommunityMember diff --git a/app/models.py b/app/models.py index 49ba82bf..f66dea22 100644 --- a/app/models.py +++ b/app/models.py @@ -1,8 +1,11 @@ from datetime import datetime, timedelta, date from hashlib import md5 from time import time +from typing import List + from flask import current_app, escape from flask_login import UserMixin +from sqlalchemy import or_ from werkzeug.security import generate_password_hash, check_password_hash from flask_babel import _, lazy_gettext as _l from sqlalchemy.orm import backref @@ -73,6 +76,7 @@ class Community(db.Model): return self.icon.source_url return '' + def header_image(self) -> str: if self.image_id is not None: if self.image.file_path is not None: @@ -93,6 +97,14 @@ class Community(db.Model): else: return self.ap_id + def moderators(self): + return CommunityMember.query.filter((CommunityMember.community_id == self.id) & + (or_( + CommunityMember.is_owner, + CommunityMember.is_moderator + )) + ).all() + class User(UserMixin, db.Model): id = db.Column(db.Integer, primary_key=True) @@ -201,6 +213,10 @@ class User(UserMixin, db.Model): else: return SUBSCRIPTION_NONMEMBER + def communities(self) -> List[Community]: + return Community.query.filter(Community.banned == False).\ + join(CommunityMember).filter(CommunityMember.is_banned == False).all() + @staticmethod def verify_reset_password_token(token): try: @@ -320,6 +336,7 @@ class CommunityMember(db.Model): community_id = db.Column(db.Integer, db.ForeignKey('community.id'), primary_key=True) is_moderator = db.Column(db.Boolean, default=False) is_owner = db.Column(db.Boolean, default=False) + is_banned = db.Column(db.Boolean, default=False) created_at = db.Column(db.DateTime, default=datetime.utcnow) diff --git a/app/sorting.py b/app/sorting.py new file mode 100644 index 00000000..441bd64b --- /dev/null +++ b/app/sorting.py @@ -0,0 +1,50 @@ +# from https://medium.com/hacking-and-gonzo/how-reddit-ranking-algorithms-work-ef111e33d0d9 + +from math import sqrt, log +from datetime import datetime, timedelta + + + +epoch = datetime(1970, 1, 1) + + +def epoch_seconds(date): + td = date - epoch + return td.days * 86400 + td.seconds + (float(td.microseconds) / 1000000) + + +def score(ups, downs): + return ups - downs + + +# used for ranking stories +def hot(ups, downs, date): + s = score(ups, downs) + order = log(max(abs(s), 1), 10) + sign = 1 if s > 0 else -1 if s < 0 else 0 + seconds = epoch_seconds(date) - 1134028003 # this value seems to be an arbitrary time in 2005. + return round(sign * order + seconds / 45000, 7) + + +# used for ranking comments +def _confidence(ups, downs): + n = ups + downs + + if n == 0: + return 0 + + z = 1.281551565545 + p = float(ups) / n + + left = p + 1 / (2 * n) * z * z + right = z * sqrt(p * (1 - p) / n + z * z / (4 * n * n)) + under = 1 + 1 / n * z * z + + return (left - right) / under + + +def confidence(ups, downs): + if ups + downs == 0: + return 0 + else: + return _confidence(ups, downs) \ No newline at end of file diff --git a/app/static/js/scripts.js b/app/static/js/scripts.js index 2768f7eb..2a1acbf0 100644 --- a/app/static/js/scripts.js +++ b/app/static/js/scripts.js @@ -6,7 +6,7 @@ document.addEventListener("DOMContentLoaded", function () { // fires after all resources have loaded, including stylesheets and js files window.addEventListener("load", function () { - + setupPostTypeTabs(); // when choosing the type of your new post, store the chosen tab in a hidden field so the backend knows which fields to check }); @@ -21,6 +21,34 @@ function setupCommunityNameInput() { } } +function setupPostTypeTabs() { + + const tabEl = document.querySelector('#discussion-tab') + if(tabEl) { + tabEl.addEventListener('show.bs.tab', event => { + document.getElementById('type').value = 'discussion'; + }); + } + const tabE2 = document.querySelector('#link-tab') + if(tabE2) { + tabE2.addEventListener('show.bs.tab', event => { + document.getElementById('type').value = 'link'; + }); + } + const tabE3 = document.querySelector('#image-tab') + if(tabE3) { + tabE3.addEventListener('show.bs.tab', event => { + document.getElementById('type').value = 'image'; + }); + } + const tabE4 = document.querySelector('#poll-tab') + if(tabE4) { + tabE4.addEventListener('show.bs.tab', event => { + document.getElementById('type').value = 'poll'; + }); + } +} + function titleToURL(title) { // Convert the title to lowercase and replace spaces with hyphens diff --git a/app/static/styles.css b/app/static/styles.css index a70db224..bc63e564 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -239,6 +239,14 @@ nav.navbar { opacity: 0.9; } +.tab-content > .tab-pane { + border-right: solid 1px #dee2e6; + border-bottom: solid 1px #dee2e6; + border-left: solid 1px #dee2e6; + border-radius: 0 0 5px 5px; + padding: 10px 15px 0 15px; +} + .dropdown-menu .dropdown-item.active { background-color: #0071CE; } diff --git a/app/static/styles.scss b/app/static/styles.scss index e8541325..86bd5460 100644 --- a/app/static/styles.scss +++ b/app/static/styles.scss @@ -35,6 +35,14 @@ nav.navbar { opacity: 0.9; } +.tab-content > .tab-pane { + border-right: solid 1px #dee2e6; + border-bottom: solid 1px #dee2e6; + border-left: solid 1px #dee2e6; + border-radius: 0 0 5px 5px; + padding: 10px 15px 0 15px; +} + .dropdown-menu { .dropdown-item.active { background-color: $primary-colour; diff --git a/app/templates/community/add_post.html b/app/templates/community/add_post.html new file mode 100644 index 00000000..5c6d8f87 --- /dev/null +++ b/app/templates/community/add_post.html @@ -0,0 +1,69 @@ +{% extends "base.html" %} +{% from 'bootstrap/form.html' import render_form, render_field %} + +{% block app_content %} +
+
+

{{ _('Create post') }}

+
+ {{ form.csrf_token() }} + {{ render_field(form.communities) }} + +
+
+ {{ render_field(form.discussion_title) }} + {{ render_field(form.discussion_body) }} +
+ +
+ {{ render_field(form.image_title) }} + {{ render_field(form.image_file) }} +
+
+ Poll +
+
+ {{ render_field(form.type) }} +
+
+ {{ render_field(form.nsfw) }} +
+
+ {{ render_field(form.nsfl) }} +
+
+ +
+
+ {{ render_field(form.notify) }} + {{ render_field(form.submit) }} +
+
+ +
+
+
+

{{ community.title }}

+
+
+

{{ community.description|safe }}

+

{{ community.rules|safe }}

+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/community/community.html b/app/templates/community/community.html index 36186036..e550c494 100644 --- a/app/templates/community/community.html +++ b/app/templates/community/community.html @@ -8,7 +8,7 @@

{{ community.title }}

- {% else %} + {% elif community.icon_image() != '' %}
@@ -17,6 +17,8 @@

{{ community.title }}

+ {% else %} +

{{ community.title }}

{% endif %} @@ -33,7 +35,7 @@ {% endif %}
@@ -46,8 +48,8 @@

{{ _('About community') }}

-

{{ community.description }}

-

{{ community.rules }}

+

{{ community.description|safe }}

+

{{ community.rules|safe }}

{% if len(mods) > 0 and not community.private_mods %}

Moderators

    diff --git a/app/utils.py b/app/utils.py index 19ef9d96..32ec014e 100644 --- a/app/utils.py +++ b/app/utils.py @@ -1,4 +1,3 @@ -import functools import random from urllib.parse import urlparse @@ -42,7 +41,7 @@ def get_request(uri, params=None, headers=None) -> requests.Response: # saves an arbitrary object into a persistent key-value store. cached. -@cache.cached(timeout=50) +@cache.memoize(timeout=50) def get_setting(name: str, default=None): setting = Settings.query.filter_by(name=name).first() if setting is None: @@ -55,7 +54,7 @@ def get_setting(name: str, default=None): def set_setting(name: str, value): setting = Settings.query.filter_by(name=name).first() if setting is None: - db.session.append(Settings(name=name, value=json.dumps(value))) + db.session.add(Settings(name=name, value=json.dumps(value))) else: setting.value = json.dumps(value) db.session.commit() diff --git a/migrations/versions/f8200275644a_community_ban_field.py b/migrations/versions/f8200275644a_community_ban_field.py new file mode 100644 index 00000000..73f45b3d --- /dev/null +++ b/migrations/versions/f8200275644a_community_ban_field.py @@ -0,0 +1,48 @@ +"""community ban field + +Revision ID: f8200275644a +Revises: 6b84580a94cd +Create Date: 2023-09-16 19:21:13.085722 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'f8200275644a' +down_revision = '6b84580a94cd' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('community_member', schema=None) as batch_op: + batch_op.add_column(sa.Column('is_banned', sa.Boolean(), nullable=True)) + + with op.batch_alter_table('post', schema=None) as batch_op: + batch_op.add_column(sa.Column('comments_enabled', sa.Boolean(), nullable=True)) + + with op.batch_alter_table('post_reply', schema=None) as batch_op: + batch_op.add_column(sa.Column('body_html_safe', sa.Boolean(), nullable=True)) + batch_op.add_column(sa.Column('ap_create_id', sa.String(length=100), nullable=True)) + batch_op.add_column(sa.Column('ap_announce_id', sa.String(length=100), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('post_reply', schema=None) as batch_op: + batch_op.drop_column('ap_announce_id') + batch_op.drop_column('ap_create_id') + batch_op.drop_column('body_html_safe') + + with op.batch_alter_table('post', schema=None) as batch_op: + batch_op.drop_column('comments_enabled') + + with op.batch_alter_table('community_member', schema=None) as batch_op: + batch_op.drop_column('is_banned') + + # ### end Alembic commands ### diff --git a/pyfedi.py b/pyfedi.py index 6f099658..4c455328 100644 --- a/pyfedi.py +++ b/pyfedi.py @@ -20,7 +20,7 @@ def app_context_processor(): # NB there needs to be an identical function in cb @app.shell_context_processor def make_shell_context(): - return {'db': db} + return {'db': db, 'app': app} with app.app_context():