federation: handle remote subscriptions and unsubscriptions

This commit is contained in:
rimu 2023-11-16 22:31:14 +13:00
parent c3d36cfb86
commit c9beb0c0da
8 changed files with 52 additions and 16 deletions

View file

@ -77,7 +77,7 @@ def nodeinfo2():
nodeinfo_data = { nodeinfo_data = {
"version": "2.0", "version": "2.0",
"software": { "software": {
"name": "pyfedi", "name": "PieFed",
"version": "0.1" "version": "0.1"
}, },
"protocols": [ "protocols": [
@ -151,18 +151,18 @@ def community_profile(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', ''):
abort(404) abort(404)
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:
community = Community.query.filter_by(name=actor, banned=False, ap_id=None).first() community: Community = Community.query.filter_by(name=actor, banned=False, ap_id=None).first()
if community is not None: if community is not None:
if 'application/ld+json' in request.headers.get('Accept', ''): if 'application/ld+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": "Group", "type": "Group",
"id": f"https://{server}/c/{actor}", "id": f"https://{server}/c/{actor}",
"name": actor.title, "name": community.title,
"summary": actor.description, "summary": community.description,
"sensitive": True if actor.nsfw or actor.nsfl else False, "sensitive": True if community.nsfw or community.nsfl else False,
"preferredUsername": actor, "preferredUsername": actor,
"inbox": f"https://{server}/c/{actor}/inbox", "inbox": f"https://{server}/c/{actor}/inbox",
"outbox": f"https://{server}/c/{actor}/outbox", "outbox": f"https://{server}/c/{actor}/outbox",
@ -170,7 +170,7 @@ def community_profile(actor):
"moderators": f"https://{server}/c/{actor}/moderators", "moderators": f"https://{server}/c/{actor}/moderators",
"featured": f"https://{server}/c/{actor}/featured", "featured": f"https://{server}/c/{actor}/featured",
"attributedTo": f"https://{server}/c/{actor}/moderators", "attributedTo": f"https://{server}/c/{actor}/moderators",
"postingRestrictedToMods": actor.restricted_to_mods, "postingRestrictedToMods": community.restricted_to_mods,
"url": f"https://{server}/c/{actor}", "url": f"https://{server}/c/{actor}",
"publicKey": { "publicKey": {
"id": f"https://{server}/c/{actor}#main-key", "id": f"https://{server}/c/{actor}#main-key",
@ -180,13 +180,13 @@ def community_profile(actor):
"endpoints": { "endpoints": {
"sharedInbox": f"https://{server}/inbox" "sharedInbox": f"https://{server}/inbox"
}, },
"published": community.created.isoformat(), "published": community.created_at.isoformat(),
"updated": community.last_active.isoformat(), "updated": community.last_active.isoformat(),
} }
if community.avatar_id is not None: if community.icon_id is not None:
actor_data["icon"] = { actor_data["icon"] = {
"type": "Image", "type": "Image",
"url": f"https://{server}/avatars/{community.avatar.file_path}" "url": f"https://{server}/avatars/{community.icon.file_path}"
} }
resp = jsonify(actor_data) resp = jsonify(actor_data)
resp.content_type = 'application/activity+json' resp.content_type = 'application/activity+json'
@ -411,6 +411,22 @@ 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'
elif request_json['type'] == 'Undo':
if request_json['object']['type'] == 'Follow': # Unsubscribe from a community
community_ap_id = request_json['actor']
user_ap_id = request_json['object']['actor']
user = find_actor_or_create(user_ap_id)
community = find_actor_or_create(community_ap_id)
if user and community:
member = CommunityMember.query.filter_by(user_id=user.id, community_id=community.id).first()
db.session.delete(member)
db.session.commit()
activity_log.result = 'success'
elif request_json['object']['type'] == 'Like': # Undoing an upvote
...
elif request_json['object']['type'] == 'Dislike': # Undoing a downvote
...
else: else:
activity_log.exception_message = 'Instance banned' activity_log.exception_message = 'Instance banned'
else: else:
@ -422,6 +438,7 @@ def shared_inbox():
activity_log.result = 'failure' activity_log.result = 'failure'
db.session.add(activity_log) db.session.add(activity_log)
db.session.commit() db.session.commit()
return ''
@bp.route('/c/<actor>/outbox', methods=['GET']) @bp.route('/c/<actor>/outbox', methods=['GET'])

View file

@ -236,6 +236,7 @@ class HttpSignature:
headers_string, headers_string,
public_key, public_key,
) )
return True
@classmethod @classmethod
def signed_request( def signed_request(
@ -298,7 +299,7 @@ class HttpSignature:
) )
# Announce ourselves with an agent similar to Mastodon # Announce ourselves with an agent similar to Mastodon
headers["User-Agent"] = 'pyfedi' headers["User-Agent"] = 'PieFed'
# Send the request with all those headers except the pseudo one # Send the request with all those headers except the pseudo one
del headers["(request-target)"] del headers["(request-target)"]

View file

@ -214,7 +214,7 @@ def find_actor_or_create(actor: str) -> Union[User, Community, None]:
return None return None
user = User.query.filter_by( user = User.query.filter_by(
ap_profile_id=actor).first() # finds users formatted like https://kbin.social/u/tables ap_profile_id=actor).first() # finds users formatted like https://kbin.social/u/tables
if user.banned: if user and user.banned:
return None return None
if user is None: if user is None:
user = Community.query.filter_by(ap_profile_id=actor).first() user = Community.query.filter_by(ap_profile_id=actor).first()
@ -225,6 +225,7 @@ def find_actor_or_create(actor: str) -> Union[User, Community, None]:
params={'resource': f"acct:{address}@{server}"}) params={'resource': f"acct:{address}@{server}"})
if webfinger_data.status_code == 200: if webfinger_data.status_code == 200:
webfinger_json = webfinger_data.json() webfinger_json = webfinger_data.json()
webfinger_data.close()
for links in webfinger_json['links']: for links in webfinger_json['links']:
if 'rel' in links and links['rel'] == 'self': # this contains the URL of the activitypub profile if 'rel' in links and links['rel'] == 'self': # this contains the URL of the activitypub profile
type = links['type'] if 'type' in links else 'application/activity+json' type = links['type'] if 'type' in links else 'application/activity+json'
@ -233,11 +234,12 @@ def find_actor_or_create(actor: str) -> Union[User, Community, None]:
# to see the structure of the json contained in actor_data, do a GET to https://lemmy.world/c/technology with header Accept: application/activity+json # to see the structure of the json contained in actor_data, do a GET to https://lemmy.world/c/technology with header Accept: application/activity+json
if actor_data.status_code == 200: if actor_data.status_code == 200:
activity_json = actor_data.json() activity_json = actor_data.json()
actor_data.close()
if activity_json['type'] == 'Person': if activity_json['type'] == 'Person':
user = User(user_name=activity_json['preferredUsername'], user = User(user_name=activity_json['preferredUsername'],
email=f"{address}@{server}", email=f"{address}@{server}",
about=parse_summary(activity_json), about=parse_summary(activity_json),
created_at=activity_json['published'], created=activity_json['published'],
ap_id=f"{address}@{server}", ap_id=f"{address}@{server}",
ap_public_url=activity_json['id'], ap_public_url=activity_json['id'],
ap_profile_id=activity_json['id'], ap_profile_id=activity_json['id'],

View file

@ -14,6 +14,18 @@ class AddLocalCommunity(FlaskForm):
nsfw = BooleanField('18+ NSFW') nsfw = BooleanField('18+ NSFW')
submit = SubmitField(_l('Create')) submit = SubmitField(_l('Create'))
def validate(self, extra_validators=None):
if not super().validate():
return False
if self.url.data.strip() == '':
self.url.errors.append(_('Url is required.'))
return False
else:
if '-' in self.url.data.strip():
self.url.errors.append(_('- cannot be in Url. Use _ instead?'))
return False
return True
class SearchRemoteCommunity(FlaskForm): class SearchRemoteCommunity(FlaskForm):
address = StringField(_l('Server address'), validators=[DataRequired()]) address = StringField(_l('Server address'), validators=[DataRequired()])

View file

@ -15,6 +15,7 @@ from app.models import Community, CommunityMember
@bp.route('/', methods=['GET', 'POST']) @bp.route('/', methods=['GET', 'POST'])
@bp.route('/index', methods=['GET', 'POST']) @bp.route('/index', methods=['GET', 'POST'])
def index(): def index():
raise Exception('cowbell')
verification_warning() verification_warning()
return render_template('index.html') return render_template('index.html')

View file

@ -297,7 +297,6 @@ class User(UserMixin, db.Model):
{'user_id': self.id}) {'user_id': self.id})
class ActivityLog(db.Model): class ActivityLog(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), index=True) user_id = db.Column(db.Integer, db.ForeignKey('user.id'), index=True)

View file

@ -177,5 +177,5 @@ function setupHideButtons() {
function titleToURL(title) { function titleToURL(title) {
// Convert the title to lowercase and replace spaces with hyphens // Convert the title to lowercase and replace spaces with hyphens
return title.toLowerCase().replace(/\s+/g, '-'); return title.toLowerCase().replace(/\s+/g, '_');
} }

View file

@ -33,8 +33,12 @@ def getmtime(filename):
# do a GET request to a uri, return the result # do a GET request to a uri, return the result
def get_request(uri, params=None, headers=None) -> requests.Response: def get_request(uri, params=None, headers=None) -> requests.Response:
if headers is None:
headers = {'User-Agent': 'PieFed/1.0'}
else:
headers.update({'User-Agent': 'PieFed/1.0'})
try: try:
response = requests.get(uri, params=params, headers=headers, timeout=1, allow_redirects=True) response = requests.get(uri, params=params, headers=headers, timeout=5, allow_redirects=True)
except requests.exceptions.SSLError as invalid_cert: except requests.exceptions.SSLError as invalid_cert:
# Not our problem if the other end doesn't have proper SSL # Not our problem if the other end doesn't have proper SSL
current_app.logger.info(f"{uri} {invalid_cert}") current_app.logger.info(f"{uri} {invalid_cert}")