diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 76566266..84a0e642 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,7 @@ starting any large pieces of work to ensure alignment with the roadmap, architec 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! Our code needs to be simple enough that new developers of all +probably enjoy your time here! The codebase needs to be simple enough that new developers of all skill levels can easily understand what's going on and onboard quickly without a lot of upfront time investment. Sometimes this will mean writing slightly more verbose/boring code or avoiding the use of advanced design patterns. @@ -33,6 +33,8 @@ VS Code coders are encouraged to try the free community edition of PyCharm but i Use PEP 8 conventions for line length, naming, indentation. Use descriptive commit messages. +Database model classes are singular. As in "Car", not "Cars". + ### Directory structure Where possible, the structure should match the URL structure of the site. e.g. "domain.com/admin" @@ -50,8 +52,6 @@ helpful functions that pertain to modules in that directory only. Changes to this file are turned into changes in the DB by using '[migrations](https://www.onlinetutorialspoint.com/flask/flask-how-to-upgrade-or-downgrade-database-migrations.html)'. - /community/* pertains to viewing, posting within and managing communities. -Python developers who are new to Flask will be able to quickly become productive with - # Code of conduct ## Our Pledge diff --git a/app/activitypub/routes.py b/app/activitypub/routes.py index 87a65f73..b6e037e0 100644 --- a/app/activitypub/routes.py +++ b/app/activitypub/routes.py @@ -271,6 +271,7 @@ def shared_inbox(): if post is not None: db.session.add(post) + community.post_count += 1 db.session.commit() else: post_id, parent_comment_id, root_id = find_reply_parent(in_reply_to) @@ -294,6 +295,7 @@ def shared_inbox(): if post_reply is not None: db.session.add(post_reply) + community.post_reply_count += 1 db.session.commit() else: activity_log.exception_message = 'Unacceptable type: ' + object_type @@ -398,6 +400,7 @@ def shared_inbox(): if join_request: member = CommunityMember(user_id=user.id, community_id=community.id) db.session.add(member) + community.subscriptions_count += 1 db.session.commit() activity_log.result = 'success' else: diff --git a/app/community/forms.py b/app/community/forms.py index 39c8a79e..464a5dda 100644 --- a/app/community/forms.py +++ b/app/community/forms.py @@ -68,3 +68,8 @@ class CreatePost(FlaskForm): return False return True + + +class NewReplyForm(FlaskForm): + body = TextAreaField(_l('Body'), render_kw={'placeholder': 'What are your thoughts?'}) + submit = SubmitField(_l('Comment')) diff --git a/app/community/routes.py b/app/community/routes.py index 8f0fb86a..bff5f933 100644 --- a/app/community/routes.py +++ b/app/community/routes.py @@ -2,13 +2,13 @@ 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_login import login_user, logout_user, current_user, login_required 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, CreatePost +from app.community.forms import SearchRemoteCommunity, AddLocalCommunity, CreatePost, NewReplyForm 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 @@ -70,8 +70,8 @@ def add_remote(): def show_community(community: Community): 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) + is_moderator = current_user.is_authenticated and any(mod.user_id == current_user.id for mod in mods) + is_owner = current_user.is_authenticated and any(mod.user_id == current_user.id and mod.is_owner == True for mod in mods) if community.private_mods: mod_list = [] @@ -80,10 +80,11 @@ def show_community(community: Community): mod_list = User.query.filter(User.id.in_(mod_user_ids)).all() return render_template('community/community.html', community=community, title=community.title, - is_moderator=is_moderator, is_owner=is_owner, mods=mod_list) + is_moderator=is_moderator, is_owner=is_owner, mods=mod_list, posts=community.posts) @bp.route('//subscribe', methods=['GET']) +@login_required def subscribe(actor): remote = False actor = actor.strip() @@ -137,6 +138,7 @@ def subscribe(actor): @bp.route('//unsubscribe', methods=['GET']) +@login_required def unsubscribe(actor): community = actor_to_community(actor) @@ -162,6 +164,7 @@ def unsubscribe(actor): @bp.route('//submit', methods=['GET', 'POST']) +@login_required def add_post(actor): community = actor_to_community(actor) form = CreatePost() @@ -194,8 +197,11 @@ def add_post(actor): else: raise Exception('invalid post type') db.session.add(post) + community.post_count += 1 db.session.commit() + # todo: federate post creation out to followers + flash('Post has been added') return redirect(f"/c/{community.link()}") else: @@ -204,3 +210,13 @@ def add_post(actor): return render_template('community/add_post.html', title=_('Add post to community'), form=form, community=community, images_disabled=images_disabled) + + +@bp.route('/post/') +def show_post(post_id: int): + post = Post.query.get_or_404(post_id) + mods = post.community.moderators() + is_moderator = current_user.is_authenticated and any(mod.user_id == current_user.id for mod in mods) + form = NewReplyForm() + return render_template('community/post.html', title=post.title, post=post, is_moderator=is_moderator, + canonical=post.ap_id, form=form) diff --git a/app/main/routes.py b/app/main/routes.py index 41154428..0e76f04b 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -2,13 +2,13 @@ from datetime import datetime from app import db from app.main import bp -from flask import g, jsonify, flash, request +from flask import g, session, flash, request from flask_moment import moment 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, get_setting +from app.utils import render_template, get_setting, gibberish from app.models import Community, CommunityMember @@ -43,9 +43,3 @@ def list_local_communities(): 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) - - - -@bp.before_app_request -def before_request(): - g.locale = str(get_locale()) \ No newline at end of file diff --git a/app/models.py b/app/models.py index f66dea22..42517c59 100644 --- a/app/models.py +++ b/app/models.py @@ -255,8 +255,8 @@ 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) - created_at = db.Column(db.DateTime, index=True, default=datetime.utcnow) - posted_at = db.Column(db.DateTime, index=True, default=datetime.utcnow) + 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) ip = db.Column(db.String(50)) up_votes = db.Column(db.Integer, default=0) @@ -435,6 +435,23 @@ class ActivityPubLog(db.Model): created_at = db.Column(db.DateTime, default=datetime.utcnow) +class Filter(db.Model): + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(50)) + filter_posts = db.Column(db.Boolean, default=True) + filter_replies = db.Column(db.Boolean, default=False) + hide_type = db.Column(db.Integer, default=0) # 0 = hide with warning, 1 = hide completely + user_id = db.Column(db.Integer, db.ForeignKey('user.id')) + + +class FilterKeyword(db.Model): + id = db.Column(db.Integer, primary_key=True) + keyword = db.Column(db.String(100)) + filter_id = db.Column(db.Integer, db.ForeignKey('filter.id')) + user_id = db.Column(db.Integer, db.ForeignKey('user.id')) + + + @login.user_loader def load_user(id): return User.query.get(int(id)) diff --git a/app/static/images/external_link_black.svg b/app/static/images/external_link_black.svg new file mode 100644 index 00000000..d7f3d978 --- /dev/null +++ b/app/static/images/external_link_black.svg @@ -0,0 +1,19 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + \ No newline at end of file diff --git a/app/static/scss/_colours.scss b/app/static/scss/_colours.scss index 645f70d6..6949caaa 100644 --- a/app/static/scss/_colours.scss +++ b/app/static/scss/_colours.scss @@ -2,3 +2,6 @@ $primary-colour: #0071CE; $primary-colour-hover: #0A5CA0; $green: #00b550; $green-hover: #83f188; +$light-grey: #ddd; +$grey: #bbb; +$dark-grey: #777; \ No newline at end of file diff --git a/app/static/structure.css b/app/static/structure.css index 94d7b1dc..e0e09d69 100644 --- a/app/static/structure.css +++ b/app/static/structure.css @@ -277,4 +277,17 @@ fieldset legend { height: auto; } +.post_list .post_teaser { + border-bottom: solid 2px #ddd; + padding-top: 8px; + padding-bottom: 8px; +} +.post_list .post_teaser h3 { + font-size: 120%; + margin-top: 8px; +} +.post_list .post_teaser h3 a { + text-decoration: none; +} + /*# sourceMappingURL=structure.css.map */ diff --git a/app/static/structure.scss b/app/static/structure.scss index 44c6ac76..55d5455e 100644 --- a/app/static/structure.scss +++ b/app/static/structure.scss @@ -64,3 +64,21 @@ nav, etc which are used site-wide */ height: auto; } } + +.post_list { + .post_teaser { + + h3 { + font-size: 120%; + margin-top: 8px; + + a { + text-decoration: none; + } + } + + border-bottom: solid 2px $light-grey; + padding-top: 8px; + padding-bottom: 8px; + } +} \ No newline at end of file diff --git a/app/static/styles.css b/app/static/styles.css index bc63e564..26e15124 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -288,4 +288,11 @@ nav.navbar { left: 26px; } +.external_link_icon { + width: 12px; + height: 12px; + margin-left: 4px; + margin-bottom: 3px; +} + /*# sourceMappingURL=styles.css.map */ diff --git a/app/static/styles.scss b/app/static/styles.scss index 86bd5460..1a43bd87 100644 --- a/app/static/styles.scss +++ b/app/static/styles.scss @@ -83,4 +83,11 @@ nav.navbar { top: 104px; left: 26px; -} \ No newline at end of file +} + +.external_link_icon { + width: 12px; + height: 12px; + margin-left: 4px; + margin-bottom: 3px; +} diff --git a/app/templates/base.html b/app/templates/base.html index d96bc9e0..849d9dd1 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -26,6 +26,9 @@ + {% if canonical %} + + {% endif %} {% endblock %} @@ -77,8 +80,8 @@ {% endblock %} {% block scripts %} - {{ moment.include_moment() }} - {{ moment.lang(g.locale) }} + {{ str(moment.include_moment()).replace(' diff --git a/app/templates/community/community.html b/app/templates/community/community.html index e550c494..7959946e 100644 --- a/app/templates/community/community.html +++ b/app/templates/community/community.html @@ -20,7 +20,43 @@ {% else %}

{{ community.title }}

{% endif %} +
+ {% for post in posts %} + + {% else %} +

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

+ {% endfor %} +
@@ -28,7 +64,7 @@
- {% if current_user.subscribed(community) %} + {% if current_user.is_authenticated and current_user.subscribed(community) %} {{ _('Unsubscribe') }} {% else %} {{ _('Subscribe') }} @@ -39,7 +75,7 @@
- +
diff --git a/app/templates/community/post.html b/app/templates/community/post.html new file mode 100644 index 00000000..9dd0f99b --- /dev/null +++ b/app/templates/community/post.html @@ -0,0 +1,120 @@ +{% extends "base.html" %} +{% from 'bootstrap/form.html' import render_form %} + +{% block app_content %} +
+
+
+ {% if post.image_id %} +
+

{{ post.title }}

+ {% if post.url %} +

{{ post.url|shorten_url }} + {% if post.type == post_type_link %} + (domain) + {% endif %} +

+ {% endif %} +

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

+
+
+ {% if post.url %} + {{ post.image.alt_text }} + {% else %} + {{ post.image.alt_text }} + {% endif %} +
+ {% else %} +

{{ post.title }}

+ {% if post.url %} +

{{ post.url|shorten_url }} + External link + {% if post.type == post_type_link %} + (domain) + {% endif %} +

+ {% endif %} +

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

+ {% endif %} +
+ + {% if post.body_html %} +
+
+ {{ post.body_html|safe }} +
+
+
+ {% endif %} + + {% if post.comments_enabled %} +
+
+ {{ render_form(form) }} +
+
+
+ {% else %} +

{{ _('Comments are disabled for this post.') }}

+ {% endif %} +
+
+ +
+
+
+ +
+
+
+
+
+ {% if current_user.is_authenticated and current_user.subscribed(post.community) %} + {{ _('Unsubscribe') }} + {% else %} + {{ _('Subscribe') }} + {% endif %} +
+ +
+
+ +
+
+
+
+
+

{{ _('About community') }}

+
+
+

{{ post.community.description|safe }}

+

{{ post.community.rules|safe }}

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

Moderators

+
    + {% for mod in mods %} +
  1. {{ mod.user_name }}
  2. + {% endfor %} +
+ {% endif %} +
+
+ {% if is_moderator %} +
+
+

{{ _('Community Settings') }}

+
+ +
+ {% endif %} +
+
+ +{% endblock %} diff --git a/app/utils.py b/app/utils.py index 32ec014e..270deead 100644 --- a/app/utils.py +++ b/app/utils.py @@ -143,3 +143,13 @@ def domain_from_url(url: str) -> Domain: domain = Domain.query.filter_by(name=parsed_url.hostname.lower()).first() return domain + +def shorten_string(input_str, max_length=50): + if len(input_str) <= max_length: + return input_str + else: + return input_str[:max_length - 3] + '…' + + +def shorten_url(input: str, max_length=20): + return shorten_string(input.replace('https://', '').replace('http://', '')) diff --git a/migrations/versions/b1d3fc38b1f7_filter_keywords.py b/migrations/versions/b1d3fc38b1f7_filter_keywords.py new file mode 100644 index 00000000..6d046616 --- /dev/null +++ b/migrations/versions/b1d3fc38b1f7_filter_keywords.py @@ -0,0 +1,47 @@ +"""filter keywords + +Revision ID: b1d3fc38b1f7 +Revises: f8200275644a +Create Date: 2023-09-20 19:35:04.332862 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'b1d3fc38b1f7' +down_revision = 'f8200275644a' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('filter', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=50), nullable=True), + sa.Column('filter_posts', sa.Boolean(), nullable=True), + sa.Column('filter_replies', sa.Boolean(), nullable=True), + sa.Column('hide_type', sa.Integer(), nullable=True), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('filter_keyword', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('keyword', sa.String(length=100), nullable=True), + sa.Column('filter_id', sa.Integer(), nullable=True), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['filter_id'], ['filter.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('filter_keyword') + op.drop_table('filter') + # ### end Alembic commands ### diff --git a/pyfedi.py b/pyfedi.py index 4c455328..20552874 100644 --- a/pyfedi.py +++ b/pyfedi.py @@ -1,11 +1,12 @@ # This file is part of pyfedi, which is licensed under the GNU General Public License (GPL) version 3.0. # You should have received a copy of the GPL along with this program. If not, see . - +from flask_babel import get_locale from app import create_app, db, cli import os, click - -from app.utils import getmtime +from flask import session, g +from app.constants import POST_TYPE_LINK, POST_TYPE_IMAGE, POST_TYPE_ARTICLE +from app.utils import getmtime, gibberish, shorten_string, shorten_url app = create_app() cli.register(app) @@ -15,7 +16,7 @@ cli.register(app) def app_context_processor(): # NB there needs to be an identical function in cb.wsgi to make this work in production def getmtime(filename): return os.path.getmtime('app/static/' + filename) - return dict(getmtime=getmtime) + return dict(getmtime=getmtime, post_type_link=POST_TYPE_LINK, post_type_image=POST_TYPE_IMAGE, post_type_article=POST_TYPE_ARTICLE) @app.shell_context_processor @@ -25,4 +26,22 @@ def make_shell_context(): with app.app_context(): app.jinja_env.globals['getmtime'] = getmtime - app.jinja_env.globals['len'] = len \ No newline at end of file + app.jinja_env.globals['len'] = len + app.jinja_env.globals['str'] = str + app.jinja_env.filters['shorten'] = shorten_string + app.jinja_env.filters['shorten_url'] = shorten_url + + +@app.before_request +def before_request(): + session['nonce'] = gibberish() + g.locale = str(get_locale()) + + +@app.after_request +def after_request(response): + response.headers['Content-Security-Policy'] = f"script-src 'self' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net 'nonce-{session['nonce']}'" + response.headers['Strict-Transport-Security'] = 'max-age=63072000; includeSubDomains; preload' + response.headers['X-Content-Type-Options'] = 'nosniff' + response.headers['X-Frame-Options'] = 'DENY' + return response