diff --git a/app/__init__.py b/app/__init__.py index 864c3c3b..d28a7f4e 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -62,6 +62,9 @@ def create_app(config_class=Config): from app.community import bp as community_bp app.register_blueprint(community_bp, url_prefix='/community') + from app.user import bp as user_bp + app.register_blueprint(user_bp) + def get_resource_as_string(name, charset='utf-8'): with app.open_resource(name) as f: return f.read().decode(charset) diff --git a/app/activitypub/routes.py b/app/activitypub/routes.py index b6e037e0..d4707f5f 100644 --- a/app/activitypub/routes.py +++ b/app/activitypub/routes.py @@ -1,6 +1,5 @@ import markdown2 import werkzeug.exceptions -from sqlalchemy import text from app import db from app.activitypub import bp @@ -8,6 +7,7 @@ from flask import request, Response, current_app, abort, jsonify, json from app.activitypub.signature import HttpSignature from app.community.routes import show_community +from app.user.routes import show_profile from app.constants import POST_TYPE_LINK, POST_TYPE_IMAGE from app.models import User, Community, CommunityJoinRequest, CommunityMember, CommunityBan, ActivityPubLog, Post, \ PostReply, Instance, PostVote, PostReplyVote, File @@ -106,7 +106,7 @@ def user_profile(actor): actor = actor.strip() user = User.query.filter_by(user_name=actor, deleted=False, banned=False, ap_id=None).first() if user is not None: - if 'application/ld+json' in request.headers.get('Accept', '') or request.accept_mimetypes.accept_json: + if 'application/ld+json' in request.headers.get('Accept', ''): server = current_app.config['SERVER_NAME'] actor_data = { "@context": default_context(), "type": "Person", @@ -122,18 +122,24 @@ def user_profile(actor): "endpoints": { "sharedInbox": f"https://{server}/inbox" }, - "published": user.created.isoformat() + "published": user.created.isoformat(), } if user.avatar_id is not None: actor_data["icon"] = { "type": "Image", "url": f"https://{server}/avatars/{user.avatar.file_path}" } + if user.about: + actor_data['source'] = { + "content": user.about, + "mediaType": "text/markdown" + } + actor_data['summary'] = allowlist_html(markdown2.markdown(user.about, safe_mode=True)) resp = jsonify(actor_data) resp.content_type = 'application/activity+json' return resp else: - return render_template('user_profile.html', user=user) + return show_profile(user) @bp.route('/c/', methods=['GET']) diff --git a/app/community/routes.py b/app/community/routes.py index bff5f933..ffc0c535 100644 --- a/app/community/routes.py +++ b/app/community/routes.py @@ -79,8 +79,13 @@ def show_community(community: Community): mod_user_ids = [mod.user_id for mod in mods] mod_list = User.query.filter(User.id.in_(mod_user_ids)).all() + if current_user.ignore_bots: + posts = community.posts.query.filter(Post.from_bot == False).all() + else: + posts = community.posts + return render_template('community/community.html', community=community, title=community.title, - is_moderator=is_moderator, is_owner=is_owner, mods=mod_list, posts=community.posts) + is_moderator=is_moderator, is_owner=is_owner, mods=mod_list, posts=posts) @bp.route('//subscribe', methods=['GET']) diff --git a/app/main/routes.py b/app/main/routes.py index 0e76f04b..7aa12dbc 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -25,21 +25,21 @@ def index(): def list_communities(): search_param = request.args.get('search', '') if search_param == '': - communities = Community.query.all() + communities = Community.query.filter_by(banned=False).all() else: query = search(select(Community), search_param, sort=True) communities = db.session.scalars(query).all() - return render_template('list_communities.html', communities=communities, search=search_param) + return render_template('list_communities.html', communities=communities, search=search_param, title=_('Communities')) @bp.route('/communities/local', methods=['GET']) def list_local_communities(): - communities = Community.query.filter_by(ap_id=None).all() - return render_template('list_communities.html', communities=communities) + communities = Community.query.filter_by(ap_id=None, banned=False).all() + return render_template('list_communities.html', communities=communities, title=_('Local communities')) @bp.route('/communities/subscribed', methods=['GET']) def list_subscribed_communities(): - communities = Community.query.join(CommunityMember).filter(CommunityMember.user_id == current_user.id).all() - return render_template('list_communities.html', communities=communities) + communities = Community.query.filter_by(banned=False).join(CommunityMember).filter(CommunityMember.user_id == current_user.id).all() + return render_template('list_communities.html', communities=communities, title=_('Subscribed communities')) diff --git a/app/models.py b/app/models.py index c1277a18..ff4283a7 100644 --- a/app/models.py +++ b/app/models.py @@ -142,6 +142,8 @@ class User(UserMixin, db.Model): stripe_subscription_id = db.Column(db.String(50)) searchable = db.Column(db.Boolean, default=True) indexable = db.Column(db.Boolean, default=False) + bot = db.Column(db.Boolean, default=False) + ignore_bots = db.Column(db.Boolean, default=False) avatar = db.relationship('File', foreign_keys=[avatar_id], single_parent=True, cascade="all, delete-orphan") cover = db.relationship('File', foreign_keys=[cover_id], single_parent=True, cascade="all, delete-orphan") @@ -180,6 +182,22 @@ class User(UserMixin, db.Model): return 'https://www.gravatar.com/avatar/{}?d=identicon&s={}'.format( digest, size) + def avatar_image(self) -> str: + if self.avatar_id is not None: + if self.avatar.file_path is not None: + return self.avatar.file_path + if self.avatar.source_url is not None: + return self.avatar.source_url + return '' + + def cover_image(self) -> str: + if self.cover_id is not None: + if self.cover.file_path is not None: + return self.cover.file_path + if self.cover.source_url is not None: + return self.cover.source_url + return '' + def get_reset_password_token(self, expires_in=600): return jwt.encode( {'reset_password': self.id, 'exp': time() + expires_in}, @@ -264,6 +282,7 @@ class Post(db.Model): nsfl = db.Column(db.Boolean, default=False) sticky = db.Column(db.Boolean, default=False) indexable = db.Column(db.Boolean, default=False) + from_bot = db.Column(db.Boolean, default=False) created_at = db.Column(db.DateTime, index=True, default=datetime.utcnow) # this is when the content arrived here posted_at = db.Column(db.DateTime, index=True, default=datetime.utcnow) # this is when the original server created it last_active = db.Column(db.DateTime, index=True, default=datetime.utcnow) @@ -305,6 +324,7 @@ class PostReply(db.Model): created_at = db.Column(db.DateTime, index=True, default=datetime.utcnow) posted_at = db.Column(db.DateTime, index=True, default=datetime.utcnow) ip = db.Column(db.String(50)) + from_bot = db.Column(db.Boolean, default=False) up_votes = db.Column(db.Integer, default=0) down_votes = db.Column(db.Integer, default=0) ranking = db.Column(db.Integer, default=0) diff --git a/app/static/scss/_typography.scss b/app/static/scss/_typography.scss index 9c640fae..aa79b5e0 100644 --- a/app/static/scss/_typography.scss +++ b/app/static/scss/_typography.scss @@ -144,8 +144,10 @@ a.no-underline { } } +$small_text: 87%; + .small, small { - font-size: 87%; + font-size: $small_text; } fieldset legend { diff --git a/app/static/structure.css b/app/static/structure.css index a44675bb..38103700 100644 --- a/app/static/structure.css +++ b/app/static/structure.css @@ -325,7 +325,7 @@ fieldset legend { font-size: 120%; margin-top: 8px; } -.post_list .post_teaser h3 a { +.post_list .post_teaser .meta_row a, .post_list .post_teaser .main_row a, .post_list .post_teaser .utilities_row a { text-decoration: none; } diff --git a/app/static/structure.scss b/app/static/structure.scss index 10739a36..22708c83 100644 --- a/app/static/structure.scss +++ b/app/static/structure.scss @@ -117,7 +117,9 @@ nav, etc which are used site-wide */ h3 { font-size: 120%; margin-top: 8px; + } + .meta_row, .main_row, .utilities_row { a { text-decoration: none; } diff --git a/app/templates/base.html b/app/templates/base.html index 849d9dd1..5b14e53b 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -52,6 +52,7 @@ {% else %} + {% endif %} diff --git a/app/templates/community/_post_reply_teaser.html b/app/templates/community/_post_reply_teaser.html new file mode 100644 index 00000000..1bbf594b --- /dev/null +++ b/app/templates/community/_post_reply_teaser.html @@ -0,0 +1,3 @@ +
+ {{ post_reply.body_html }} +
\ No newline at end of file diff --git a/app/templates/community/_post_teaser.html b/app/templates/community/_post_teaser.html new file mode 100644 index 00000000..aa13f2db --- /dev/null +++ b/app/templates/community/_post_teaser.html @@ -0,0 +1,31 @@ + \ No newline at end of file diff --git a/app/templates/community/community.html b/app/templates/community/community.html index afef3b81..a0185c5e 100644 --- a/app/templates/community/community.html +++ b/app/templates/community/community.html @@ -37,37 +37,7 @@ {% endif %}
{% for post in posts %} - + {% include 'community/_post_teaser.html' %} {% else %}

{{ _('No posts in this community yet.') }}

{% endfor %} diff --git a/app/templates/community/post.html b/app/templates/community/post.html index bdf3a250..b7ad0896 100644 --- a/app/templates/community/post.html +++ b/app/templates/community/post.html @@ -52,7 +52,9 @@ {% endif %}

{% endif %} -

submitted {{ moment(post.posted_at).fromNow() }} by {{ post.author.user_name }}

+

submitted {{ moment(post.posted_at).fromNow() }} by + {{ post.author.user_name }} +

{% endif %}
diff --git a/app/templates/user/edit_profile.html b/app/templates/user/edit_profile.html new file mode 100644 index 00000000..5c9fe213 --- /dev/null +++ b/app/templates/user/edit_profile.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} +{% from 'bootstrap/form.html' import render_form %} + +{% block app_content %} +
+
+ +

{{ _('Edit profile') }}

+
+ {{ render_form(form) }} +
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/user/edit_settings.html b/app/templates/user/edit_settings.html new file mode 100644 index 00000000..d97e5236 --- /dev/null +++ b/app/templates/user/edit_settings.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} +{% from 'bootstrap/form.html' import render_form %} + +{% block app_content %} +
+
+ +

{{ _('Change settings') }}

+
+ {{ render_form(form) }} +
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/user/show_profile.html b/app/templates/user/show_profile.html new file mode 100644 index 00000000..ee5cda92 --- /dev/null +++ b/app/templates/user/show_profile.html @@ -0,0 +1,98 @@ +{% extends "base.html" %} +{% from 'bootstrap/form.html' import render_form %} + +{% block app_content %} +
+
+ {% if user.cover_image() != '' %} +
+ +
+ +

{{ user.user_name }}

+ {% elif user.avatar_image() != '' %} +
+
+ +
+
+

{{ user.user_name }}

+
+
+ {% else %} + +

{{ user.user_name }}

+ {{ user.about_html|safe }} + {% endif %} + + {% if len(posts) > 0 %} +

Posts

+
+ {% for post in posts %} + {% include 'community/_post_teaser.html' %} + {% endfor %} +
+ {% endif %} + + {% if len(post_replies) > 0 %} +

Comments

+
+ {% for post_reply in post_replies %} + {% include 'community/_post_reply_teaser.html' %} + {% endfor %} +
+ {% endif %} +
+ +
+ {% if current_user.id == user.id %} +
+
+

{{ _('Manage') }}

+
+ +
+ {% endif %} + {% if len(moderates) > 0 %} +
+
+

{{ _('Moderates') }}

+
+
+
    + {% for community in moderates %} +
  1. + + + {{ community.display_name() }} + +
  2. + {% endfor %} +
+
+
+ {% endif %} +
+
+{% endblock %} diff --git a/app/templates/user_profile.html b/app/templates/user_profile.html deleted file mode 100644 index e69de29b..00000000 diff --git a/app/user/__init__.py b/app/user/__init__.py new file mode 100644 index 00000000..b83125c8 --- /dev/null +++ b/app/user/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +bp = Blueprint('user', __name__) + +from app.user import routes diff --git a/app/user/forms.py b/app/user/forms.py new file mode 100644 index 00000000..c9f232fd --- /dev/null +++ b/app/user/forms.py @@ -0,0 +1,30 @@ +from flask import session +from flask_login import current_user +from flask_wtf import FlaskForm +from wtforms import StringField, SubmitField, PasswordField, BooleanField, EmailField, TextAreaField, FileField +from wtforms.validators import ValidationError, DataRequired, Email, EqualTo, Length, Optional +from flask_babel import _, lazy_gettext as _l + + +class ProfileForm(FlaskForm): + email = EmailField(_l('Email address'), validators=[Email(), DataRequired(), Length(min=5, max=255)]) + password_field = PasswordField(_l('Set new password'), validators=[Optional(), Length(min=1, max=50)], + render_kw={"autocomplete": 'Off'}) + about = TextAreaField(_l('Bio'), validators=[Optional(), Length(min=3, max=5000)]) + submit = SubmitField(_l('Save profile')) + + def validate_email(self, field): + if current_user.another_account_using_email(field.data): + raise ValidationError(_l('That email address is already in use by another account')) + + +class SettingsForm(FlaskForm): + newsletter = BooleanField(_l('Subscribe to email newsletter')) + bot = BooleanField(_l('This profile is a bot')) + ignore_bots = BooleanField(_l('Hide posts by bots')) + nsfw = BooleanField(_l('Show NSFW posts')) + nsfl = BooleanField(_l('Show NSFL posts')) + searchable = BooleanField(_l('Show profile in fediverse searches')) + indexable = BooleanField(_l('Allow search engines to index this profile')) + manually_approves_followers = BooleanField(_l('Manually approve followers')) + submit = SubmitField(_l('Save settings')) \ No newline at end of file diff --git a/app/user/routes.py b/app/user/routes.py new file mode 100644 index 00000000..93fc6284 --- /dev/null +++ b/app/user/routes.py @@ -0,0 +1,82 @@ +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, login_required +from flask_babel import _ + +from app import db +from app.models import Post, Community, CommunityMember, User, PostReply +from app.user import bp +from app.user.forms import ProfileForm, SettingsForm +from app.utils import get_setting, render_template, allowlist_html +from sqlalchemy import desc, or_ + + +def show_profile(user): + posts = Post.query.filter_by(user_id=user.id).order_by(desc(Post.posted_at)).all() + moderates = Community.query.filter_by(banned=False).join(CommunityMember).filter(or_(CommunityMember.is_moderator, CommunityMember.is_owner)) + if user.id != current_user.id: + moderates = moderates.filter(Community.private_mods == False) + post_replies = PostReply.query.filter_by(user_id=user.id).order_by(desc(PostReply.posted_at)).all() + canonical = user.ap_public_url if user.ap_public_url else None + user.about_html = allowlist_html(markdown2.markdown(user.about, safe_mode=True)) + return render_template('user/show_profile.html', user=user, posts=posts, post_replies=post_replies, + moderates=moderates.all(), canonical=canonical, title=_('Posts by %(user_name)s', + user_name=user.user_name)) + + +@bp.route('/u//profile', methods=['GET', 'POST']) +def edit_profile(actor): + actor = actor.strip() + user = User.query.filter_by(user_name=actor, deleted=False, banned=False, ap_id=None).first() + if user is None: + abort(404) + form = ProfileForm() + if form.validate_on_submit(): + current_user.email = form.email.data + if form.password_field.data.strip() != '': + current_user.set_password(form.password_field.data) + current_user.about = form.about.data + db.session.commit() + + flash(_('Your changes have been saved.'), 'success') + return redirect(url_for('user.edit_profile', actor=actor)) + elif request.method == 'GET': + form.email.data = current_user.email + form.about.data = current_user.about + form.password_field.data = '' + + return render_template('user/edit_profile.html', title=_('Edit profile'), form=form, user=current_user) + + +@bp.route('/u//settings', methods=['GET', 'POST']) +def change_settings(actor): + actor = actor.strip() + user = User.query.filter_by(user_name=actor, deleted=False, banned=False, ap_id=None).first() + if user is None: + abort(404) + form = SettingsForm() + if form.validate_on_submit(): + current_user.newsletter = form.newsletter.data + current_user.bot = form.bot.data + current_user.ignore_bots = form.ignore_bots.data + current_user.show_nsfw = form.nsfw.data + current_user.show_nsfl = form.nsfl.data + current_user.searchable = form.searchable.data + current_user.indexable = form.indexable.data + current_user.ap_manually_approves_followers = form.manually_approves_followers.data + db.session.commit() + + flash(_('Your changes have been saved.'), 'success') + return redirect(url_for('user.change_settings', actor=actor)) + elif request.method == 'GET': + form.newsletter.data = current_user.newsletter + form.bot.data = current_user.bot + form.ignore_bots.data = current_user.ignore_bots + form.nsfw.data = current_user.show_nsfw + form.nsfl.data = current_user.show_nsfl + form.searchable.data = current_user.searchable + form.indexable.data = current_user.indexable + form.manually_approves_followers.data = current_user.ap_manually_approves_followers + + return render_template('user/edit_settings.html', title=_('Edit profile'), form=form, user=current_user) diff --git a/migrations/versions/8c5cc19e0670_bots.py b/migrations/versions/8c5cc19e0670_bots.py new file mode 100644 index 00000000..d7a9d890 --- /dev/null +++ b/migrations/versions/8c5cc19e0670_bots.py @@ -0,0 +1,46 @@ +"""bots + +Revision ID: 8c5cc19e0670 +Revises: b1d3fc38b1f7 +Create Date: 2023-10-07 18:13:32.600126 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '8c5cc19e0670' +down_revision = 'b1d3fc38b1f7' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('post', schema=None) as batch_op: + batch_op.add_column(sa.Column('from_bot', sa.Boolean(), nullable=True)) + + with op.batch_alter_table('post_reply', schema=None) as batch_op: + batch_op.add_column(sa.Column('from_bot', sa.Boolean(), nullable=True)) + + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.add_column(sa.Column('bot', sa.Boolean(), nullable=True)) + batch_op.add_column(sa.Column('ignore_bots', sa.Boolean(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.drop_column('ignore_bots') + batch_op.drop_column('bot') + + with op.batch_alter_table('post_reply', schema=None) as batch_op: + batch_op.drop_column('from_bot') + + with op.batch_alter_table('post', schema=None) as batch_op: + batch_op.drop_column('from_bot') + + # ### end Alembic commands ###