subscription and unsubscription to remote communities - lemmy bugs

also local cache busting
This commit is contained in:
rimu 2023-12-03 22:41:15 +13:00
parent 5752b8eaeb
commit d5d7122a3d
25 changed files with 174 additions and 90 deletions

View file

@ -1,6 +1,6 @@
# Contributing to PyFedi # Contributing to PieFed
When it matures enough, PyFedi will aim to work in a way consistent with the [Collective Code Construction Contract](https://42ity.org/c4.html). When it matures enough, PieFed will aim to work in a way consistent with the [Collective Code Construction Contract](https://42ity.org/c4.html).
Please discuss your ideas in an issue at https://codeberg.org/rimu/pyfedi/issues before 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. starting any large pieces of work to ensure alignment with the roadmap, architecture and processes.
@ -58,7 +58,7 @@ Changes to this file are turned into changes in the DB by using '[migrations](ht
## Our Pledge ## Our Pledge
We, the contributors and maintainers of the PyFedi project, pledge to create a welcoming and harassment-free environment for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. We, the contributors and maintainers of the PieFed project, pledge to create a welcoming and harassment-free environment for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
## Our Standards ## Our Standards
@ -102,4 +102,4 @@ This Code of Conduct is adapted from the [Contributor Covenant](https://www.cont
## Conclusion ## Conclusion
We all share the responsibility of upholding these standards. Let's work together to create a welcoming and inclusive environment that fosters collaboration, learning, and the growth of the PyFedi community. We all share the responsibility of upholding these standards. Let's work together to create a welcoming and inclusive environment that fosters collaboration, learning, and the growth of the PieFed community.

View file

@ -1,4 +1,4 @@
# pyfedi # PieFed
A lemmy/kbin clone written in Python with Flask. A lemmy/kbin clone written in Python with Flask.

View file

@ -1,4 +1,4 @@
# PyFedi Roadmap # PieFed Roadmap
The following are the goals for a 1.0 release, good enough for production use. Items with a ✅ are complete. The following are the goals for a 1.0 release, good enough for production use. Items with a ✅ are complete.

View file

@ -1,4 +1,4 @@
# This file is part of pyfedi, which is licensed under the GNU General Public License (GPL) version 3.0. # This file is part of PieFed, 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 <http://www.gnu.org/licenses/>. # You should have received a copy of the GPL along with this program. If not, see <http://www.gnu.org/licenses/>.
import logging import logging

View file

@ -1,20 +1,20 @@
from datetime import datetime from datetime import datetime
from app import db, constants from app import db, constants, cache
from app.activitypub import bp from app.activitypub import bp
from flask import request, Response, current_app, abort, jsonify, json from flask import request, Response, current_app, abort, jsonify, json
from app.activitypub.signature import HttpSignature from app.activitypub.signature import HttpSignature
from app.community.routes import show_community from app.community.routes import show_community
from app.user.routes import show_profile from app.user.routes import show_profile
from app.constants import POST_TYPE_LINK, POST_TYPE_IMAGE from app.constants import POST_TYPE_LINK, POST_TYPE_IMAGE, SUBSCRIPTION_MEMBER
from app.models import User, Community, CommunityJoinRequest, CommunityMember, CommunityBan, ActivityPubLog, Post, \ from app.models import User, Community, CommunityJoinRequest, CommunityMember, CommunityBan, ActivityPubLog, Post, \
PostReply, Instance, PostVote, PostReplyVote, File, AllowedInstances, BannedInstances PostReply, Instance, PostVote, PostReplyVote, File, AllowedInstances, BannedInstances
from app.activitypub.util import public_key, users_total, active_half_year, active_month, local_posts, local_comments, \ from app.activitypub.util import public_key, users_total, active_half_year, active_month, local_posts, local_comments, \
post_to_activity, find_actor_or_create, default_context, instance_blocked, find_reply_parent, find_liked_object, \ post_to_activity, find_actor_or_create, default_context, instance_blocked, find_reply_parent, find_liked_object, \
lemmy_site_data, instance_weight lemmy_site_data, instance_weight
from app.utils import gibberish, get_setting, is_image_url, allowlist_html, html_to_markdown, render_template, \ from app.utils import gibberish, get_setting, is_image_url, allowlist_html, html_to_markdown, render_template, \
domain_from_url, markdown_to_html domain_from_url, markdown_to_html, community_membership
import werkzeug.exceptions import werkzeug.exceptions
INBOX = [] INBOX = []
@ -143,7 +143,7 @@ def user_profile(actor):
user = User.query.filter_by(user_name=actor, deleted=False, banned=False, ap_id=None).first() user = User.query.filter_by(user_name=actor, deleted=False, banned=False, ap_id=None).first()
if user is not None: if user is not None:
if 'application/ld+json' in request.headers.get('Accept', ''): if 'application/ld+json' in request.headers.get('Accept', '') or 'application/activity+json' in request.headers.get('Accept', ''):
server = current_app.config['SERVER_NAME'] server = current_app.config['SERVER_NAME']
actor_data = { "@context": default_context(), actor_data = { "@context": default_context(),
"type": "Person", "type": "Person",
@ -154,12 +154,12 @@ def user_profile(actor):
"publicKey": { "publicKey": {
"id": f"https://{server}/u/{actor}#main-key", "id": f"https://{server}/u/{actor}#main-key",
"owner": f"https://{server}/u/{actor}", "owner": f"https://{server}/u/{actor}",
"publicKeyPem": user.public_key.replace("\n", "\\n") "publicKeyPem": user.public_key # .replace("\n", "\\n") #LOOKSWRONG
}, },
"endpoints": { "endpoints": {
"sharedInbox": f"https://{server}/inbox" "sharedInbox": f"https://{server}/inbox"
}, },
"published": user.created.isoformat(), "published": user.created.isoformat() + '+00:00',
} }
if user.avatar_id is not None: if user.avatar_id is not None:
actor_data["icon"] = { actor_data["icon"] = {
@ -188,7 +188,7 @@ def community_profile(actor):
actor = actor.strip() actor = actor.strip()
if '@' in actor: if '@' in actor:
# don't provide activitypub info for remote communities # don't provide activitypub info for remote communities
if 'application/ld+json' in request.headers.get('Accept', ''): if 'application/ld+json' in request.headers.get('Accept', '') or 'application/activity+json' in request.headers.get('Accept', ''):
abort(404) abort(404)
community: Community = Community.query.filter_by(ap_id=actor, banned=False).first() community: Community = Community.query.filter_by(ap_id=actor, banned=False).first()
else: else:
@ -374,7 +374,7 @@ def shared_inbox():
else: else:
activity_log.exception_message = 'Unacceptable type (kbin): ' + object_type activity_log.exception_message = 'Unacceptable type (kbin): ' + object_type
# Announce is new content and votes, lemmy style # Announce is new content and votes, mastodon style (?)
if request_json['type'] == 'Announce': if request_json['type'] == 'Announce':
if request_json['object']['type'] == 'Create': if request_json['object']['type'] == 'Create':
activity_log.activity_type = request_json['object']['type'] activity_log.activity_type = request_json['object']['type']
@ -473,7 +473,9 @@ def shared_inbox():
vote_weight = instance_weight(user.ap_domain) vote_weight = instance_weight(user.ap_domain)
liked = find_liked_object(liked_ap_id) liked = find_liked_object(liked_ap_id)
# insert into voted table # insert into voted table
if liked is not None and isinstance(liked, Post): if liked is None:
activity_log.exception_message = 'Liked object not found'
elif liked is not None and isinstance(liked, Post):
existing_vote = PostVote.query.filter_by(user_id=user.id, post_id=liked.id).first() existing_vote = PostVote.query.filter_by(user_id=user.id, post_id=liked.id).first()
if existing_vote: if existing_vote:
existing_vote.effect = vote_effect * vote_weight existing_vote.effect = vote_effect * vote_weight
@ -499,7 +501,7 @@ def shared_inbox():
... # todo: recalculate 'hotness' of liked post/reply ... # todo: recalculate 'hotness' of liked post/reply
# todo: if vote was on content in local community, federate the vote out to followers # 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 # Follow: remote user wants to join/follow one of our communities
elif request_json['type'] == 'Follow': # Follow is when someone wants to join a community elif request_json['type'] == 'Follow': # Follow is when someone wants to join a community
user_ap_id = request_json['actor'] user_ap_id = request_json['actor']
community_ap_id = request_json['object'] community_ap_id = request_json['object']
@ -511,10 +513,11 @@ def shared_inbox():
banned = CommunityBan.query.filter_by(user_id=user.id, banned = CommunityBan.query.filter_by(user_id=user.id,
community_id=community.id).first() community_id=community.id).first()
if banned is None: if banned is None:
if not user.subscribed(community): if community_membership(user, community) != SUBSCRIPTION_MEMBER:
member = CommunityMember(user_id=user.id, community_id=community.id) member = CommunityMember(user_id=user.id, community_id=community.id)
db.session.add(member) db.session.add(member)
db.session.commit() db.session.commit()
cache.delete_memoized(community_membership, user, community)
# send accept message to acknowledge the follow # send accept message to acknowledge the follow
accept = { accept = {
"@context": default_context(), "@context": default_context(),
@ -561,9 +564,11 @@ def shared_inbox():
community.subscriptions_count += 1 community.subscriptions_count += 1
db.session.commit() db.session.commit()
activity_log.result = 'success' activity_log.result = 'success'
cache.delete_memoized(community_membership, user, community)
elif request_json['type'] == 'Undo': elif request_json['type'] == 'Undo':
if request_json['object']['type'] == 'Follow': # Unsubscribe from a community if request_json['object']['type'] == 'Follow': # Unsubscribe from a community
community_ap_id = request_json['actor'] community_ap_id = request_json['object']['object']
user_ap_id = request_json['object']['actor'] user_ap_id = request_json['object']['actor']
user = find_actor_or_create(user_ap_id) user = find_actor_or_create(user_ap_id)
community = find_actor_or_create(community_ap_id) community = find_actor_or_create(community_ap_id)

View file

@ -44,6 +44,7 @@ from datetime import datetime
from dateutil import parser from dateutil import parser
from pyld import jsonld from pyld import jsonld
from app.activitypub.util import default_context
from app.constants import DATETIME_MS_FORMAT from app.constants import DATETIME_MS_FORMAT
@ -245,7 +246,7 @@ class HttpSignature:
body: dict | None, body: dict | None,
private_key: str, private_key: str,
key_id: str, key_id: str,
content_type: str = "application/json", content_type: str = "application/activity+json",
method: Literal["get", "post"] = "post", method: Literal["get", "post"] = "post",
timeout: int = 5, timeout: int = 5,
): ):
@ -266,33 +267,7 @@ class HttpSignature:
# If we have a body, add a digest and content type # If we have a body, add a digest and content type
if body is not None: if body is not None:
if '@context' not in body: # add a default json-ld context if necessary if '@context' not in body: # add a default json-ld context if necessary
body['@context'] = [ body['@context'] = default_context()
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{
"piefed": "https://piefed.social/ns#",
"lemmy": "https://join-lemmy.org/ns#",
"litepub": "http://litepub.social/ns#",
"pt": "https://joinpeertube.org/ns#",
"sc": "http://schema.org/",
"nsfl": "piefed:nsfl",
"ChatMessage": "litepub:ChatMessage",
"commentsEnabled": "pt:commentsEnabled",
"sensitive": "as:sensitive",
"matrixUserId": "lemmy:matrixUserId",
"postingRestrictedToMods": "lemmy:postingRestrictedToMods",
"removeData": "lemmy:removeData",
"stickied": "lemmy:stickied",
"moderators": {
"@type": "@id",
"@id": "lemmy:moderators"
},
"expires": "as:endTime",
"distinguished": "lemmy:distinguished",
"language": "sc:inLanguage",
"identifier": "sc:identifier"
}
]
body_bytes = json.dumps(body).encode("utf8") body_bytes = json.dumps(body).encode("utf8")
headers["Digest"] = cls.calculate_digest(body_bytes) headers["Digest"] = cls.calculate_digest(body_bytes)
headers["Content-Type"] = content_type headers["Content-Type"] = content_type

View file

@ -5,8 +5,8 @@ from app.email import send_email
def send_password_reset_email(user): def send_password_reset_email(user):
token = user.get_reset_password_token() token = user.get_reset_password_token()
send_email(_('[PyFedi] Reset Your Password'), send_email(_('[PieFed] Reset Your Password'),
sender='PyFedi <rimu@chorebuster.net>', sender='PieFed <rimu@chorebuster.net>',
recipients=[user.email], recipients=[user.email],
text_body=render_template('email/reset_password.txt', text_body=render_template('email/reset_password.txt',
user=user, token=token), user=user, token=token),
@ -15,8 +15,8 @@ def send_password_reset_email(user):
def send_welcome_email(user): def send_welcome_email(user):
send_email(_('Welcome to PyFedi'), send_email(_('Welcome to PieFed'),
sender='PyFedi <rimu@chorebuster.net>', sender='PieFed <rimu@chorebuster.net>',
recipients=[user.email], recipients=[user.email],
text_body=render_template('email/welcome.txt', user=user), text_body=render_template('email/welcome.txt', user=user),
html_body=render_template('email/welcome.html', user=user)) html_body=render_template('email/welcome.html', user=user))
@ -24,7 +24,7 @@ def send_welcome_email(user):
def send_verification_email(user): def send_verification_email(user):
send_email(_('Please verify your email address'), send_email(_('Please verify your email address'),
sender='PyFedi <rimu@chorebuster.net>', sender='PieFed <rimu@chorebuster.net>',
recipients=[user.email], recipients=[user.email],
text_body=render_template('email/verification.txt', user=user), text_body=render_template('email/verification.txt', user=user),
html_body=render_template('email/verification.html', user=user)) html_body=render_template('email/verification.html', user=user))

View file

@ -45,7 +45,7 @@ class CreatePostForm(FlaskForm):
nsfw = BooleanField(_l('NSFW')) nsfw = BooleanField(_l('NSFW'))
nsfl = BooleanField(_l('NSFL')) nsfl = BooleanField(_l('NSFL'))
notify_author = BooleanField(_l('Notify about replies')) notify_author = BooleanField(_l('Notify about replies'))
submit = SubmitField(_l('Post')) submit = SubmitField(_l('Save'))
def validate(self, extra_validators=None) -> bool: def validate(self, extra_validators=None) -> bool:
if not super().validate(): if not super().validate():

View file

@ -4,17 +4,19 @@ from flask_babel import _
from pillow_heif import register_heif_opener from pillow_heif import register_heif_opener
from sqlalchemy import or_, desc from sqlalchemy import or_, desc
from app import db, constants from app import db, constants, cache
from app.activitypub.signature import RsaKeys, HttpSignature from app.activitypub.signature import RsaKeys, HttpSignature
from app.activitypub.util import default_context
from app.community.forms import SearchRemoteCommunity, AddLocalCommunity, CreatePostForm from app.community.forms import SearchRemoteCommunity, AddLocalCommunity, CreatePostForm
from app.community.util import search_for_community, community_url_exists, actor_to_community, \ from app.community.util import search_for_community, community_url_exists, actor_to_community, \
ensure_directory_exists, opengraph_parse, url_to_thumbnail_file, save_post ensure_directory_exists, opengraph_parse, url_to_thumbnail_file, save_post
from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, POST_TYPE_LINK, POST_TYPE_ARTICLE, POST_TYPE_IMAGE from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, POST_TYPE_LINK, POST_TYPE_ARTICLE, POST_TYPE_IMAGE, \
SUBSCRIPTION_PENDING
from app.models import User, Community, CommunityMember, CommunityJoinRequest, CommunityBan, Post, \ from app.models import User, Community, CommunityMember, CommunityJoinRequest, CommunityBan, Post, \
File, PostVote File, PostVote
from app.community import bp from app.community import bp
from app.utils import get_setting, render_template, allowlist_html, markdown_to_html, validation_required, \ from app.utils import get_setting, render_template, allowlist_html, markdown_to_html, validation_required, \
shorten_string, markdown_to_text, domain_from_url, validate_image, gibberish shorten_string, markdown_to_text, domain_from_url, validate_image, gibberish, community_membership
import os import os
from PIL import Image, ImageOps from PIL import Image, ImageOps
from datetime import datetime from datetime import datetime
@ -70,7 +72,7 @@ def add_remote():
return render_template('community/add_remote.html', return render_template('community/add_remote.html',
title=_('Add remote community'), form=form, new_community=new_community, title=_('Add remote community'), form=form, new_community=new_community,
subscribed=current_user.subscribed(new_community) >= SUBSCRIPTION_MEMBER) subscribed=community_membership(current_user, new_community) >= SUBSCRIPTION_MEMBER)
# @bp.route('/c/<actor>', methods=['GET']) - defined in activitypub/routes.py, which calls this function for user requests. A bit weird. # @bp.route('/c/<actor>', methods=['GET']) - defined in activitypub/routes.py, which calls this function for user requests. A bit weird.
@ -97,7 +99,8 @@ def show_community(community: Community):
return render_template('community/community.html', community=community, title=community.title, return render_template('community/community.html', community=community, title=community.title,
is_moderator=is_moderator, is_owner=is_owner, mods=mod_list, posts=posts, description=description, is_moderator=is_moderator, is_owner=is_owner, mods=mod_list, posts=posts, description=description,
og_image=og_image, POST_TYPE_IMAGE=POST_TYPE_IMAGE, POST_TYPE_LINK=POST_TYPE_LINK) og_image=og_image, POST_TYPE_IMAGE=POST_TYPE_IMAGE, POST_TYPE_LINK=POST_TYPE_LINK, SUBSCRIPTION_PENDING=SUBSCRIPTION_PENDING,
SUBSCRIPTION_MEMBER=SUBSCRIPTION_MEMBER)
@bp.route('/<actor>/subscribe', methods=['GET']) @bp.route('/<actor>/subscribe', methods=['GET'])
@ -113,7 +116,7 @@ def subscribe(actor):
community = Community.query.filter_by(name=actor, banned=False, ap_id=None).first() community = Community.query.filter_by(name=actor, banned=False, ap_id=None).first()
if community is not None: if community is not None:
if not current_user.subscribed(community): if community_membership(current_user, community) != SUBSCRIPTION_MEMBER and community_membership(current_user, community) != SUBSCRIPTION_PENDING:
if remote: if remote:
# send ActivityPub message to remote community, asking to follow. Accept message will be sent to our shared inbox # send ActivityPub message to remote community, asking to follow. Accept message will be sent to our shared inbox
join_request = CommunityJoinRequest(user_id=current_user.id, community_id=community.id) join_request = CommunityJoinRequest(user_id=current_user.id, community_id=community.id)
@ -161,12 +164,46 @@ def unsubscribe(actor):
community = actor_to_community(actor) community = actor_to_community(actor)
if community is not None: if community is not None:
subscription = current_user.subscribed(community) subscription = community_membership(current_user, community)
if subscription: if subscription:
if subscription != SUBSCRIPTION_OWNER: if subscription != SUBSCRIPTION_OWNER:
db.session.query(CommunityMember).filter_by(user_id=current_user.id, community_id=community.id).delete() proceed = True
db.session.commit() # Undo the Follow
flash('You are unsubscribed from ' + community.title) if '@' in actor: # this is a remote community, so activitypub is needed
follow = {
"actor": f"https://{current_app.config['SERVER_NAME']}/u/{current_user.user_name}",
"to": [community.ap_profile_id],
"object": community.ap_profile_id,
"type": "Follow",
"id": f"https://{current_app.config['SERVER_NAME']}/activities/follow/{gibberish(15)}"
}
undo = {
'actor': current_user.profile_id(),
'to': [community.ap_profile_id],
'type': 'Undo',
'id': f"https://{current_app.config['SERVER_NAME']}/activities/undo/" + gibberish(15),
'object': follow
}
try:
message = HttpSignature.signed_request(community.ap_inbox_url, undo, current_user.private_key,
current_user.profile_id() + '#main-key')
if message.status_code != 200:
flash('Response status code was not 200', 'warning')
current_app.logger.error('Response code for unsubscription attempt was ' +
str(message.status_code) + ' ' + message.text)
proceed = False
except Exception as ex:
proceed = False
flash('Failed to send request to unsubscribe: ' + str(ex), 'error')
current_app.logger.error("Exception while trying to unsubscribe" + str(ex))
if proceed:
db.session.query(CommunityMember).filter_by(user_id=current_user.id, community_id=community.id).delete()
db.session.query(CommunityJoinRequest).filter_by(user_id=current_user.id, community_id=community.id).delete()
db.session.commit()
flash('You are unsubscribed from ' + community.title)
cache.delete_memoized(community_membership, current_user, community)
else: else:
# todo: community deletion # todo: community deletion
flash('You need to make someone else the owner before unsubscribing.', 'warning') flash('You need to make someone else the owner before unsubscribing.', 'warning')

View file

@ -56,6 +56,7 @@ def search_for_community(address: str):
ap_profile_id=community_json['id'], ap_profile_id=community_json['id'],
ap_followers_url=community_json['followers'], ap_followers_url=community_json['followers'],
ap_inbox_url=community_json['endpoints']['sharedInbox'], ap_inbox_url=community_json['endpoints']['sharedInbox'],
ap_moderators_url=community_json['attributedTo'] if 'attributedTo' in community_json else None,
ap_fetched_at=datetime.utcnow(), ap_fetched_at=datetime.utcnow(),
ap_domain=server, ap_domain=server,
public_key=community_json['publicKey']['publicKeyPem'], public_key=community_json['publicKey']['publicKeyPem'],

View file

@ -13,6 +13,7 @@ SUBSCRIPTION_OWNER = 3
SUBSCRIPTION_MODERATOR = 2 SUBSCRIPTION_MODERATOR = 2
SUBSCRIPTION_MEMBER = 1 SUBSCRIPTION_MEMBER = 1
SUBSCRIPTION_NONMEMBER = 0 SUBSCRIPTION_NONMEMBER = 0
SUBSCRIPTION_BANNED = -1 SUBSCRIPTION_PENDING = -1
SUBSCRIPTION_BANNED = -2
THREAD_CUTOFF_DEPTH = 4 THREAD_CUTOFF_DEPTH = 4

View file

@ -1,5 +1,6 @@
from app import db from app import db, cache
from app.constants import SUBSCRIPTION_PENDING, SUBSCRIPTION_MEMBER
from app.main import bp from app.main import bp
from flask import g, session, flash, request from flask import g, session, flash, request
from flask_moment import moment from flask_moment import moment
@ -8,7 +9,6 @@ from flask_babel import _, get_locale
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy_searchable import search from sqlalchemy_searchable import search
from app.utils import render_template, get_setting, gibberish from app.utils import render_template, get_setting, gibberish
from app.models import Community, CommunityMember from app.models import Community, CommunityMember
@ -29,21 +29,29 @@ def list_communities():
query = search(select(Community), search_param, sort=True) query = search(select(Community), search_param, sort=True)
communities = db.session.scalars(query).all() communities = db.session.scalars(query).all()
return render_template('list_communities.html', communities=communities, search=search_param, title=_('Communities')) return render_template('list_communities.html', communities=communities, search=search_param, title=_('Communities'),
SUBSCRIPTION_PENDING=SUBSCRIPTION_PENDING, SUBSCRIPTION_MEMBER=SUBSCRIPTION_MEMBER)
@bp.route('/communities/local', methods=['GET']) @bp.route('/communities/local', methods=['GET'])
def list_local_communities(): def list_local_communities():
verification_warning() verification_warning()
communities = Community.query.filter_by(ap_id=None, banned=False).all() communities = Community.query.filter_by(ap_id=None, banned=False).all()
return render_template('list_communities.html', communities=communities, title=_('Local communities')) return render_template('list_communities.html', communities=communities, title=_('Local communities'),
SUBSCRIPTION_PENDING=SUBSCRIPTION_PENDING, SUBSCRIPTION_MEMBER=SUBSCRIPTION_MEMBER)
@bp.route('/communities/subscribed', methods=['GET']) @bp.route('/communities/subscribed', methods=['GET'])
def list_subscribed_communities(): def list_subscribed_communities():
verification_warning() verification_warning()
communities = Community.query.filter_by(banned=False).join(CommunityMember).filter(CommunityMember.user_id == current_user.id).all() 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')) return render_template('list_communities.html', communities=communities, title=_('Subscribed communities'),
SUBSCRIPTION_PENDING=SUBSCRIPTION_PENDING, SUBSCRIPTION_MEMBER=SUBSCRIPTION_MEMBER)
@bp.route('/test')
def test():
...
def verification_warning(): def verification_warning():

View file

@ -17,7 +17,7 @@ import jwt
import os import os
from app.constants import SUBSCRIPTION_NONMEMBER, SUBSCRIPTION_MEMBER, SUBSCRIPTION_MODERATOR, SUBSCRIPTION_OWNER, \ from app.constants import SUBSCRIPTION_NONMEMBER, SUBSCRIPTION_MEMBER, SUBSCRIPTION_MODERATOR, SUBSCRIPTION_OWNER, \
SUBSCRIPTION_BANNED SUBSCRIPTION_BANNED, SUBSCRIPTION_PENDING
class FullTextSearchQuery(BaseQuery, SearchQueryMixin): class FullTextSearchQuery(BaseQuery, SearchQueryMixin):
@ -87,6 +87,7 @@ class Community(db.Model):
ap_fetched_at = db.Column(db.DateTime) ap_fetched_at = db.Column(db.DateTime)
ap_deleted_at = db.Column(db.DateTime) ap_deleted_at = db.Column(db.DateTime)
ap_inbox_url = db.Column(db.String(255)) ap_inbox_url = db.Column(db.String(255))
ap_moderators_url = db.Column(db.String(255))
ap_domain = db.Column(db.String(255)) ap_domain = db.Column(db.String(255))
banned = db.Column(db.Boolean, default=False) banned = db.Column(db.Boolean, default=False)
@ -276,11 +277,10 @@ class User(UserMixin, db.Model):
return True return True
return self.expires < datetime(2019, 9, 1) return self.expires < datetime(2019, 9, 1)
@cache.memoize(timeout=50) def subscribed(self, community_id: int) -> int:
def subscribed(self, community: Community) -> int: if community_id is None:
if community is None:
return False return False
subscription:CommunityMember = CommunityMember.query.filter_by(user_id=self.id, community_id=community.id).first() subscription:CommunityMember = CommunityMember.query.filter_by(user_id=self.id, community_id=community_id).first()
if subscription: if subscription:
if subscription.is_banned: if subscription.is_banned:
return SUBSCRIPTION_BANNED return SUBSCRIPTION_BANNED
@ -291,7 +291,11 @@ class User(UserMixin, db.Model):
else: else:
return SUBSCRIPTION_MEMBER return SUBSCRIPTION_MEMBER
else: else:
return SUBSCRIPTION_NONMEMBER join_request = CommunityJoinRequest.query.filter_by(user_id=self.id, community_id=community_id).first()
if join_request:
return SUBSCRIPTION_PENDING
else:
return SUBSCRIPTION_NONMEMBER
def communities(self) -> List[Community]: def communities(self) -> List[Community]:
return Community.query.filter(Community.banned == False).\ return Community.query.filter(Community.banned == False).\
@ -388,7 +392,7 @@ class Post(db.Model):
image = db.relationship(File, lazy='joined', foreign_keys=[image_id], cascade="all, delete") image = db.relationship(File, lazy='joined', foreign_keys=[image_id], cascade="all, delete")
domain = db.relationship('Domain', lazy='joined', foreign_keys=[domain_id]) domain = db.relationship('Domain', lazy='joined', foreign_keys=[domain_id])
author = db.relationship('User', lazy='joined', foreign_keys=[user_id]) author = db.relationship('User', lazy='joined', overlaps='posts', foreign_keys=[user_id])
@classmethod @classmethod
def get_by_ap_id(cls, ap_id): def get_by_ap_id(cls, ap_id):

View file

@ -26,8 +26,8 @@
{{ render_field(form.discussion_body) }} {{ render_field(form.discussion_body) }}
</div> </div>
<div class="tab-pane fade" id="link-tab-pane" role="tabpanel" aria-labelledby="profile-tab" tabindex="0"> <div class="tab-pane fade" id="link-tab-pane" role="tabpanel" aria-labelledby="profile-tab" tabindex="0">
{{ render_field(form.link_title) }}
{{ render_field(form.link_url) }} {{ render_field(form.link_url) }}
{{ render_field(form.link_title) }}
</div> </div>
<div class="tab-pane fade" id="image-tab-pane" role="tabpanel" aria-labelledby="contact-tab" tabindex="0"> <div class="tab-pane fade" id="image-tab-pane" role="tabpanel" aria-labelledby="contact-tab" tabindex="0">
{{ render_field(form.image_title) }} {{ render_field(form.image_title) }}
@ -39,6 +39,9 @@
</div> </div>
{{ render_field(form.type) }} {{ render_field(form.type) }}
<div class="row mt-4"> <div class="row mt-4">
<div class="col-md-3">
{{ render_field(form.notify_author) }}
</div>
<div class="col-md-1"> <div class="col-md-1">
{{ render_field(form.nsfw) }} {{ render_field(form.nsfw) }}
</div> </div>
@ -49,7 +52,7 @@
</div> </div>
</div> </div>
{{ render_field(form.notify_author) }}
{{ render_field(form.submit) }} {{ render_field(form.submit) }}
</form> </form>
</div> </div>

View file

@ -49,8 +49,10 @@
<div class="card-body"> <div class="card-body">
<div class="row"> <div class="row">
<div class="col-6"> <div class="col-6">
{% if current_user.is_authenticated and current_user.subscribed(community) %} {% if current_user.is_authenticated and community_membership(current_user, community) == SUBSCRIPTION_MEMBER %}
<a class="w-100 btn btn-primary" href="/community/{{ community.link() }}/unsubscribe">{{ _('Unsubscribe') }}</a> <a class="w-100 btn btn-primary" href="/community/{{ community.link() }}/unsubscribe">{{ _('Unsubscribe') }}</a>
{% elif current_user.is_authenticated and community_membership(current_user, community) == SUBSCRIPTION_PENDING %}
<a class="w-100 btn btn-outline-secondary" href="/community/{{ community.link() }}/unsubscribe">{{ _('Pending') }}</a>
{% else %} {% else %}
<a class="w-100 btn btn-primary" href="/community/{{ community.link() }}/subscribe">{{ _('Subscribe') }}</a> <a class="w-100 btn btn-primary" href="/community/{{ community.link() }}/subscribe">{{ _('Subscribe') }}</a>
{% endif %} {% endif %}

View file

@ -1,11 +1,11 @@
<p><a href="https://pyfedi.social/"><img src="https://pyfedi.social/images/PyFedi-logo.png" style="max-width: 100%; margin-bottom: 20px;" width="250" height="48" alt="PyFedi logo" /></a></p> <p><a href="https://piefed.social/"><img src="https://piefed.social/images/PyFedi-logo.png" style="max-width: 100%; margin-bottom: 20px;" width="250" height="48" alt="PieFed logo" /></a></p>
<p>Hello {{ first_name }} and welcome!</p> <p>Hello {{ first_name }} and welcome!</p>
<p>I'm Rimu, the founder of PieFed and I'd like to personally thank you for signing up.</p> <p>I'm Rimu, the founder of PieFed and I'd like to personally thank you for signing up.</p>
<p>I'd also like to give you the tools and information you need to get the most out of it. Let's get you up and running:</p> <p>I'd also like to give you the tools and information you need to get the most out of it. Let's get you up and running:</p>
<ol> <ol>
<li>Helpful resources goes here</li> <li>Helpful resources goes here</li>
</ol> </ol>
<p>I hope this helps. For more information please see <a href="https://pyfedi.social/faq.php">the FAQ</a> or ask me anything by replying to this email.</p> <p>I hope this helps. For more information please see <a href="https://piefed.social/faq.php">the FAQ</a> or ask me anything by replying to this email.</p>
<p>Warm regards,</p> <p>Warm regards,</p>
<p>Rimu Atkinson<br /> <p>Rimu Atkinson<br />
Founder</p> Founder</p>

View file

@ -6,7 +6,7 @@ I'd also like to give you the tools and information you need to get the most out
1. Helpful resources go here 1. Helpful resources go here
I hope this helps. For more information please see the FAQ at https://pyfedi.social/faq.php or ask me anything by replying to this email. I hope this helps. For more information please see the FAQ at https://piefed.social/faq.php or ask me anything by replying to this email.
Warm regards, Warm regards,

View file

@ -50,8 +50,10 @@
<td>{{ community.post_reply_count }}</td> <td>{{ community.post_reply_count }}</td>
<td>{{ moment(community.last_active).fromNow(refresh=True) }}</td> <td>{{ moment(community.last_active).fromNow(refresh=True) }}</td>
<td>{% if current_user.is_authenticated %} <td>{% if current_user.is_authenticated %}
{% if current_user.subscribed(community) %} {% if community_membership(current_user, community) == SUBSCRIPTION_MEMBER %}
<a class="btn btn-primary btn-sm" href="/community/{{ community.link() }}/unsubscribe">Unsubscribe</a> <a class="btn btn-primary btn-sm" href="/community/{{ community.link() }}/unsubscribe">Unsubscribe</a>
{% elif community_membership(current_user, community) == SUBSCRIPTION_PENDING %}
<a class="btn btn-outline-secondary btn-sm" href="/community/{{ community.link() }}/unsubscribe">Pending</a>
{% else %} {% else %}
<a class="btn btn-primary btn-sm" href="/community/{{ community.link() }}/subscribe">Subscribe</a> <a class="btn btn-primary btn-sm" href="/community/{{ community.link() }}/subscribe">Subscribe</a>
{% endif %} {% endif %}

View file

@ -21,7 +21,7 @@
<div class="card-body"> <div class="card-body">
<div class="row"> <div class="row">
<div class="col-6"> <div class="col-6">
{% if current_user.is_authenticated and current_user.subscribed(post.community) %} {% if current_user.is_authenticated and community_membership(current_user, post.community) %}
<a class="w-100 btn btn-primary" href="/community/{{ post.community.link() }}/unsubscribe">{{ _('Unsubscribe') }}</a> <a class="w-100 btn btn-primary" href="/community/{{ post.community.link() }}/unsubscribe">{{ _('Unsubscribe') }}</a>
{% else %} {% else %}
<a class="w-100 btn btn-primary" href="/community/{{ post.community.link() }}/subscribe">{{ _('Subscribe') }}</a> <a class="w-100 btn btn-primary" href="/community/{{ post.community.link() }}/subscribe">{{ _('Subscribe') }}</a>

View file

@ -61,7 +61,7 @@
<div class="card-body"> <div class="card-body">
<div class="row"> <div class="row">
<div class="col-6"> <div class="col-6">
{% if current_user.is_authenticated and current_user.subscribed(post.community) %} {% if current_user.is_authenticated and community_membership(current_user, post.community) %}
<a class="w-100 btn btn-primary" href="/community/{{ post.community.link() }}/unsubscribe">{{ _('Unsubscribe') }}</a> <a class="w-100 btn btn-primary" href="/community/{{ post.community.link() }}/unsubscribe">{{ _('Unsubscribe') }}</a>
{% else %} {% else %}
<a class="w-100 btn btn-primary" href="/community/{{ post.community.link() }}/subscribe">{{ _('Subscribe') }}</a> <a class="w-100 btn btn-primary" href="/community/{{ post.community.link() }}/subscribe">{{ _('Subscribe') }}</a>

View file

@ -112,7 +112,7 @@
<div class="card-body"> <div class="card-body">
<div class="row"> <div class="row">
<div class="col-6"> <div class="col-6">
{% if current_user.is_authenticated and current_user.subscribed(post.community) %} {% if current_user.is_authenticated and community_membership(current_user, post.community) %}
<a class="w-100 btn btn-primary" href="/community/{{ post.community.link() }}/unsubscribe">{{ _('Unsubscribe') }}</a> <a class="w-100 btn btn-primary" href="/community/{{ post.community.link() }}/unsubscribe">{{ _('Unsubscribe') }}</a>
{% else %} {% else %}
<a class="w-100 btn btn-primary" href="/community/{{ post.community.link() }}/subscribe">{{ _('Subscribe') }}</a> <a class="w-100 btn btn-primary" href="/community/{{ post.community.link() }}/subscribe">{{ _('Subscribe') }}</a>

View file

@ -50,8 +50,8 @@
{{ render_field(form.discussion_body) }} {{ render_field(form.discussion_body) }}
</div> </div>
<div class="tab-pane fade" id="link-tab-pane" role="tabpanel" aria-labelledby="profile-tab" tabindex="0"> <div class="tab-pane fade" id="link-tab-pane" role="tabpanel" aria-labelledby="profile-tab" tabindex="0">
{{ render_field(form.link_title) }}
{{ render_field(form.link_url) }} {{ render_field(form.link_url) }}
{{ render_field(form.link_title) }}
</div> </div>
<div class="tab-pane fade" id="image-tab-pane" role="tabpanel" aria-labelledby="contact-tab" tabindex="0"> <div class="tab-pane fade" id="image-tab-pane" role="tabpanel" aria-labelledby="contact-tab" tabindex="0">
{{ render_field(form.image_title) }} {{ render_field(form.image_title) }}
@ -63,17 +63,21 @@
</div> </div>
{{ render_field(form.type) }} {{ render_field(form.type) }}
<div class="row mt-4"> <div class="row mt-4">
<div class="col-md-3">
{{ render_field(form.notify_author) }}
</div>
<div class="col-md-1"> <div class="col-md-1">
{{ render_field(form.nsfw) }} {{ render_field(form.nsfw) }}
</div> </div>
<div class="col-md-1"> <div class="col-md-1">
{{ render_field(form.nsfl) }} {{ render_field(form.nsfl) }}
</div> </div>
<div class="col"> <div class="col">
</div> </div>
</div> </div>
{{ render_field(form.notify_author) }}
{{ render_field(form.submit) }} {{ render_field(form.submit) }}
</form> </form>
</div> </div>

View file

@ -14,7 +14,7 @@ from flask_login import current_user
from sqlalchemy import text from sqlalchemy import text
from app import db, cache from app import db, cache
from app.models import Settings, Domain, Instance, BannedInstances, User from app.models import Settings, Domain, Instance, BannedInstances, User, Community
# Flask's render_template function, with support for themes added # Flask's render_template function, with support for themes added
@ -198,6 +198,15 @@ def user_access(permission: str, user_id: int) -> bool:
return has_access is not None return has_access is not None
@cache.memoize(timeout=10)
def community_membership(user: User, community: Community) -> int:
# @cache.memoize works with User.subscribed but cache.delete_memoized does not, making it bad to use on class methods.
# however cache.memoize and cache.delete_memoized works fine with normal functions
if community is None:
return False
return user.subscribed(community.id)
def retrieve_block_list(): def retrieve_block_list():
try: try:
response = requests.get('https://github.com/rimu/no-qanon/blob/master/domains.txt', timeout=1) response = requests.get('https://github.com/rimu/no-qanon/blob/master/domains.txt', timeout=1)

View file

@ -0,0 +1,32 @@
"""save moderators url
Revision ID: b36dac7696d1
Revises: cae2e31293e8
Create Date: 2023-12-01 13:02:31.139428
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'b36dac7696d1'
down_revision = 'cae2e31293e8'
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('ap_moderators_url', sa.String(length=255), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('community', schema=None) as batch_op:
batch_op.drop_column('ap_moderators_url')
# ### end Alembic commands ###

View file

@ -6,7 +6,7 @@ from app import create_app, db, cli
import os, click import os, click
from flask import session, g, json from flask import session, g, json
from app.constants import POST_TYPE_LINK, POST_TYPE_IMAGE, POST_TYPE_ARTICLE from app.constants import POST_TYPE_LINK, POST_TYPE_IMAGE, POST_TYPE_ARTICLE
from app.utils import getmtime, gibberish, shorten_string, shorten_url, digits, user_access from app.utils import getmtime, gibberish, shorten_string, shorten_url, digits, user_access, community_membership
app = create_app() app = create_app()
cli.register(app) cli.register(app)
@ -29,6 +29,7 @@ with app.app_context():
app.jinja_env.globals['len'] = len app.jinja_env.globals['len'] = len
app.jinja_env.globals['digits'] = digits app.jinja_env.globals['digits'] = digits
app.jinja_env.globals['str'] = str app.jinja_env.globals['str'] = str
app.jinja_env.globals['community_membership'] = community_membership
app.jinja_env.globals['json_loads'] = json.loads app.jinja_env.globals['json_loads'] = json.loads
app.jinja_env.globals['user_access'] = user_access app.jinja_env.globals['user_access'] = user_access
app.jinja_env.filters['shorten'] = shorten_string app.jinja_env.filters['shorten'] = shorten_string