2023-09-09 20:46:40 +12:00
|
|
|
import werkzeug.exceptions
|
2023-08-05 21:24:10 +12:00
|
|
|
from sqlalchemy import text
|
|
|
|
|
|
|
|
from app import db
|
|
|
|
from app.activitypub import bp
|
2023-09-08 20:04:01 +12:00
|
|
|
from flask import request, Response, render_template, current_app, abort, jsonify, json
|
2023-08-29 22:01:06 +12:00
|
|
|
|
2023-09-08 20:04:01 +12:00
|
|
|
from app.activitypub.signature import HttpSignature
|
2023-08-29 22:01:06 +12:00
|
|
|
from app.community.routes import show_community
|
2023-09-09 20:46:40 +12:00
|
|
|
from app.models import User, Community, CommunityJoinRequest, CommunityMember, CommunityBan, ActivityPubLog
|
2023-08-10 21:13:37 +12:00
|
|
|
from app.activitypub.util import public_key, users_total, active_half_year, active_month, local_posts, local_comments, \
|
2023-09-08 20:04:01 +12:00
|
|
|
post_to_activity, find_actor_or_create
|
2023-09-09 20:46:40 +12:00
|
|
|
from app.utils import gibberish
|
2023-08-05 21:24:10 +12:00
|
|
|
|
|
|
|
INBOX = []
|
|
|
|
|
|
|
|
|
|
|
|
@bp.route('/.well-known/webfinger')
|
|
|
|
def webfinger():
|
|
|
|
if request.args.get('resource'):
|
|
|
|
query = request.args.get('resource') # acct:alice@tada.club
|
|
|
|
if 'acct:' in query:
|
|
|
|
actor = query.split(':')[1].split('@')[0] # alice
|
|
|
|
elif 'https:' in query or 'http:' in query:
|
|
|
|
actor = query.split('/')[-1]
|
|
|
|
else:
|
|
|
|
return 'Webfinger regex failed to match'
|
|
|
|
|
|
|
|
seperator = 'u'
|
|
|
|
type = 'Person'
|
|
|
|
user = User.query.filter_by(user_name=actor.strip(), deleted=False, banned=False, ap_id=None).first()
|
|
|
|
if user is None:
|
|
|
|
community = Community.query.filter_by(name=actor.strip(), ap_id=None).first()
|
|
|
|
if community is None:
|
|
|
|
return ''
|
|
|
|
seperator = 'c'
|
|
|
|
type = 'Group'
|
|
|
|
|
|
|
|
webfinger_data = {
|
|
|
|
"subject": f"acct:{actor}@{current_app.config['SERVER_NAME']}",
|
|
|
|
"aliases": [f"https://{current_app.config['SERVER_NAME']}/{seperator}/{actor}"],
|
|
|
|
"links": [
|
|
|
|
{
|
|
|
|
"rel": "http://webfinger.net/rel/profile-page",
|
|
|
|
"type": "text/html",
|
|
|
|
"href": f"https://{current_app.config['SERVER_NAME']}/{seperator}/{actor}"
|
|
|
|
},
|
|
|
|
{
|
|
|
|
"rel": "self",
|
|
|
|
"type": "application/activity+json",
|
|
|
|
"href": f"https://{current_app.config['SERVER_NAME']}/{seperator}/{actor}",
|
|
|
|
"properties": {
|
|
|
|
"https://www.w3.org/ns/activitystreams#type": type
|
|
|
|
}
|
|
|
|
}
|
|
|
|
]
|
|
|
|
}
|
|
|
|
resp = jsonify(webfinger_data)
|
|
|
|
resp.headers.add_header('Access-Control-Allow-Origin', '*')
|
|
|
|
return resp
|
|
|
|
else:
|
|
|
|
abort(404)
|
|
|
|
|
|
|
|
|
|
|
|
@bp.route('/.well-known/nodeinfo')
|
|
|
|
def nodeinfo():
|
|
|
|
nodeinfo_data = {"links": [{"rel": "http://nodeinfo.diaspora.software/ns/schema/2.0",
|
|
|
|
"href": f"https://{current_app.config['SERVER_NAME']}/nodeinfo/2.0"}]}
|
|
|
|
return jsonify(nodeinfo_data)
|
|
|
|
|
|
|
|
|
|
|
|
@bp.route('/nodeinfo/2.0')
|
|
|
|
def nodeinfo2():
|
|
|
|
|
|
|
|
nodeinfo_data = {
|
|
|
|
"version": "2.0",
|
|
|
|
"software": {
|
|
|
|
"name": "pyfedi",
|
|
|
|
"version": "0.1"
|
|
|
|
},
|
|
|
|
"protocols": [
|
|
|
|
"activitypub"
|
|
|
|
],
|
|
|
|
"usage": {
|
|
|
|
"users": {
|
2023-08-10 21:13:37 +12:00
|
|
|
"total": users_total(),
|
|
|
|
"activeHalfyear": active_half_year(),
|
|
|
|
"activeMonth": active_month()
|
2023-08-05 21:24:10 +12:00
|
|
|
},
|
2023-08-10 21:13:37 +12:00
|
|
|
"localPosts": local_posts(),
|
|
|
|
"localComments": local_comments()
|
2023-08-05 21:24:10 +12:00
|
|
|
},
|
|
|
|
"openRegistrations": True
|
|
|
|
}
|
|
|
|
return jsonify(nodeinfo_data)
|
|
|
|
|
|
|
|
|
2023-08-10 21:13:37 +12:00
|
|
|
@bp.route('/u/<actor>', methods=['GET'])
|
|
|
|
def user_profile(actor):
|
|
|
|
""" Requests to this endpoint can be for a JSON representation of the user, or a HTML rendering of their profile.
|
|
|
|
The two types of requests are differentiated by the header """
|
|
|
|
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:
|
|
|
|
server = current_app.config['SERVER_NAME']
|
|
|
|
actor_data = { "@context": [
|
|
|
|
"https://www.w3.org/ns/activitystreams",
|
|
|
|
"https://w3id.org/security/v1",
|
|
|
|
{
|
|
|
|
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
|
|
|
|
"schema": "http://schema.org#",
|
|
|
|
"PropertyValue": "schema:PropertyValue",
|
|
|
|
"value": "schema:value"
|
|
|
|
}
|
|
|
|
],
|
|
|
|
"type": "Person",
|
|
|
|
"id": f"https://{server}/u/{actor}",
|
|
|
|
"preferredUsername": actor,
|
|
|
|
"inbox": f"https://{server}/u/{actor}/inbox",
|
|
|
|
"outbox": f"https://{server}/u/{actor}/outbox",
|
|
|
|
"publicKey": {
|
|
|
|
"id": f"https://{server}/u/{actor}#main-key",
|
|
|
|
"owner": f"https://{server}/u/{actor}",
|
|
|
|
"publicKeyPem": user.public_key.replace("\n", "\\n")
|
|
|
|
},
|
|
|
|
"endpoints": {
|
|
|
|
"sharedInbox": f"https://{server}/inbox"
|
|
|
|
},
|
|
|
|
"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}"
|
|
|
|
}
|
|
|
|
resp = jsonify(actor_data)
|
|
|
|
resp.content_type = 'application/activity+json'
|
|
|
|
return resp
|
|
|
|
else:
|
|
|
|
return render_template('user_profile.html', user=user)
|
|
|
|
|
|
|
|
|
|
|
|
@bp.route('/c/<actor>', methods=['GET'])
|
|
|
|
def community_profile(actor):
|
|
|
|
""" Requests to this endpoint can be for a JSON representation of the community, or a HTML rendering of it.
|
|
|
|
The two types of requests are differentiated by the header """
|
|
|
|
actor = actor.strip()
|
2023-08-29 22:01:06 +12:00
|
|
|
if '@' in actor:
|
|
|
|
# don't provide activitypub info for remote communities
|
|
|
|
if 'application/ld+json' in request.headers.get('Accept', ''):
|
|
|
|
abort(404)
|
|
|
|
community = Community.query.filter_by(ap_id=actor, banned=False).first()
|
|
|
|
else:
|
|
|
|
community = Community.query.filter_by(name=actor, banned=False, ap_id=None).first()
|
2023-08-10 21:13:37 +12:00
|
|
|
if community is not None:
|
2023-08-29 22:01:06 +12:00
|
|
|
if 'application/ld+json' in request.headers.get('Accept', ''):
|
2023-08-10 21:13:37 +12:00
|
|
|
server = current_app.config['SERVER_NAME']
|
|
|
|
actor_data = {"@context": [
|
|
|
|
"https://www.w3.org/ns/activitystreams",
|
2023-09-08 20:04:01 +12:00
|
|
|
"https://w3id.org/security/v1"
|
2023-08-10 21:13:37 +12:00
|
|
|
],
|
|
|
|
"type": "Group",
|
|
|
|
"id": f"https://{server}/c/{actor}",
|
|
|
|
"name": actor.title,
|
|
|
|
"summary": actor.description,
|
|
|
|
"sensitive": True if actor.nsfw or actor.nsfl else False,
|
|
|
|
"preferredUsername": actor,
|
|
|
|
"inbox": f"https://{server}/c/{actor}/inbox",
|
|
|
|
"outbox": f"https://{server}/c/{actor}/outbox",
|
|
|
|
"followers": f"https://{server}/c/{actor}/followers",
|
|
|
|
"moderators": f"https://{server}/c/{actor}/moderators",
|
|
|
|
"featured": f"https://{server}/c/{actor}/featured",
|
|
|
|
"attributedTo": f"https://{server}/c/{actor}/moderators",
|
|
|
|
"postingRestrictedToMods": actor.restricted_to_mods,
|
|
|
|
"url": f"https://{server}/c/{actor}",
|
|
|
|
"publicKey": {
|
|
|
|
"id": f"https://{server}/c/{actor}#main-key",
|
|
|
|
"owner": f"https://{server}/c/{actor}",
|
|
|
|
"publicKeyPem": community.public_key.replace("\n", "\\n")
|
|
|
|
},
|
|
|
|
"endpoints": {
|
|
|
|
"sharedInbox": f"https://{server}/inbox"
|
|
|
|
},
|
|
|
|
"published": community.created.isoformat(),
|
|
|
|
"updated": community.last_active.isoformat(),
|
|
|
|
}
|
|
|
|
if community.avatar_id is not None:
|
|
|
|
actor_data["icon"] = {
|
|
|
|
"type": "Image",
|
|
|
|
"url": f"https://{server}/avatars/{community.avatar.file_path}"
|
|
|
|
}
|
|
|
|
resp = jsonify(actor_data)
|
|
|
|
resp.content_type = 'application/activity+json'
|
|
|
|
return resp
|
2023-08-29 22:01:06 +12:00
|
|
|
else: # browser request - return html
|
|
|
|
return show_community(community)
|
|
|
|
else:
|
|
|
|
abort(404)
|
2023-08-10 21:13:37 +12:00
|
|
|
|
|
|
|
|
2023-09-08 20:04:01 +12:00
|
|
|
@bp.route('/inbox', methods=['GET', 'POST'])
|
|
|
|
def shared_inbox():
|
|
|
|
if request.method == 'POST':
|
2023-09-09 20:46:40 +12:00
|
|
|
# save all incoming data to aid in debugging and development
|
|
|
|
activity_log = ActivityPubLog(direction='in', activity_json=request.data, result='failure')
|
|
|
|
|
|
|
|
try:
|
|
|
|
request_json = request.get_json(force=True)
|
|
|
|
except werkzeug.exceptions.BadRequest as e:
|
|
|
|
activity_log.exception_message = 'Unable to parse json body: ' + e.description
|
|
|
|
db.session.add(activity_log)
|
|
|
|
db.session.commit()
|
|
|
|
return
|
|
|
|
else:
|
|
|
|
if 'id' in request_json:
|
|
|
|
activity_log.activity_id = request_json['id']
|
|
|
|
|
2023-09-08 20:04:01 +12:00
|
|
|
actor = find_actor_or_create(request_json['actor'])
|
|
|
|
if actor is not None:
|
|
|
|
if HttpSignature.verify_request(request, actor.public_key, skip_date=True):
|
|
|
|
if 'type' in request_json:
|
2023-09-09 20:46:40 +12:00
|
|
|
activity_log.activity_type = request_json['type']
|
2023-09-08 20:04:01 +12:00
|
|
|
if request_json['type'] == 'Announce':
|
|
|
|
...
|
2023-09-09 20:46:40 +12:00
|
|
|
# remote user wants to follow one of our communities
|
2023-09-08 20:04:01 +12:00
|
|
|
elif request_json['type'] == 'Follow':
|
2023-09-09 20:46:40 +12:00
|
|
|
user_ap_id = request_json['actor']
|
|
|
|
community_ap_id = request_json['object']
|
|
|
|
follow_id = request_json['id']
|
|
|
|
user = find_actor_or_create(user_ap_id)
|
|
|
|
community = find_actor_or_create(community_ap_id)
|
|
|
|
if user is not None and community is not None:
|
|
|
|
# check if user is banned from this community
|
|
|
|
banned = CommunityBan.query.filter_by(user_id=user.id,
|
|
|
|
community_id=community.id).first()
|
|
|
|
if banned is None:
|
|
|
|
if not user.subscribed(community):
|
|
|
|
member = CommunityMember(user_id=user.id, community_id=community.id)
|
|
|
|
db.session.add(member)
|
|
|
|
db.session.commit()
|
|
|
|
# send accept message to acknowledge the follow
|
|
|
|
accept = {
|
|
|
|
"@context": [
|
|
|
|
"https://www.w3.org/ns/activitystreams",
|
|
|
|
"https://w3id.org/security/v1",
|
|
|
|
],
|
|
|
|
"actor": community.ap_profile_id,
|
|
|
|
"to": [
|
|
|
|
user.ap_profile_id
|
|
|
|
],
|
|
|
|
"object": {
|
|
|
|
"actor": user.ap_profile_id,
|
|
|
|
"to": None,
|
|
|
|
"object": community.ap_profile_id,
|
|
|
|
"type": "Follow",
|
|
|
|
"id": follow_id
|
|
|
|
},
|
|
|
|
"type": "Accept",
|
|
|
|
"id": f"https://{current_app.config['SERVER_NAME']}/activities/accept/" + gibberish(32)
|
|
|
|
}
|
|
|
|
try:
|
|
|
|
HttpSignature.signed_request(user.ap_inbox_url, accept, community.private_key,
|
|
|
|
f"https://{current_app.config['SERVER_NAME']}/c/{community.name}#main-key")
|
|
|
|
except Exception as e:
|
|
|
|
accept_log = ActivityPubLog(direction='out', activity_json=json.dumps(accept),
|
|
|
|
result='failure', activity_id=accept['id'],
|
|
|
|
exception_message = 'could not send Accept' + str(e))
|
|
|
|
db.session.add(accept_log)
|
|
|
|
db.session.commit()
|
|
|
|
return
|
|
|
|
activity_log.result = 'success'
|
|
|
|
else:
|
|
|
|
activity_log.exception_message = 'user is banned from this community'
|
|
|
|
# remote server is accepting our previous follow request
|
2023-09-08 20:04:01 +12:00
|
|
|
elif request_json['type'] == 'Accept':
|
|
|
|
if request_json['object']['type'] == 'Follow':
|
|
|
|
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)
|
|
|
|
join_request = CommunityJoinRequest.query.filter_by(user_id=user.id,
|
|
|
|
community_id=community.id).first()
|
|
|
|
if join_request:
|
|
|
|
member = CommunityMember(user_id=user.id, community_id=community.id)
|
|
|
|
db.session.add(member)
|
|
|
|
db.session.commit()
|
2023-09-09 20:46:40 +12:00
|
|
|
activity_log.result = 'success'
|
|
|
|
|
|
|
|
else:
|
|
|
|
activity_log.exception_message = 'Could not verify signature'
|
|
|
|
else:
|
|
|
|
activity_log.exception_message = 'Actor could not be found: ' + request_json['actor']
|
2023-09-08 20:04:01 +12:00
|
|
|
|
2023-09-09 20:46:40 +12:00
|
|
|
db.session.add(activity_log)
|
|
|
|
db.session.commit()
|
2023-09-08 20:04:01 +12:00
|
|
|
|
|
|
|
|
2023-08-10 21:13:37 +12:00
|
|
|
@bp.route('/c/<actor>/outbox', methods=['GET'])
|
|
|
|
def community_outbox(actor):
|
|
|
|
actor = actor.strip()
|
|
|
|
community = Community.query.filter_by(name=actor, banned=False, ap_id=None).first()
|
|
|
|
if community is not None:
|
|
|
|
posts = community.posts.limit(50).all()
|
|
|
|
|
|
|
|
community_data = {
|
|
|
|
"@context": [
|
|
|
|
"https://www.w3.org/ns/activitystreams",
|
|
|
|
"https://w3id.org/security/v1",
|
|
|
|
],
|
|
|
|
"type": "OrderedCollection",
|
|
|
|
"id": f"https://{current_app.config['SERVER_NAME']}/c/{actor}/outbox",
|
|
|
|
"totalItems": len(posts),
|
|
|
|
"orderedItems": []
|
|
|
|
}
|
|
|
|
|
|
|
|
for post in posts:
|
|
|
|
community_data['orderedItems'].append(post_to_activity(post, community))
|
2023-08-05 21:24:10 +12:00
|
|
|
|
2023-08-10 21:13:37 +12:00
|
|
|
return jsonify(community_data)
|
2023-08-05 21:24:10 +12:00
|
|
|
|
|
|
|
|
|
|
|
@bp.route('/inspect')
|
|
|
|
def inspect():
|
|
|
|
return Response(b'<br><br>'.join(INBOX), status=200)
|
|
|
|
|
|
|
|
|
|
|
|
@bp.route('/users/<actor>/inbox', methods=['GET', 'POST'])
|
|
|
|
def inbox(actor):
|
|
|
|
""" To post to this inbox, you could use curl:
|
|
|
|
$ curl -d '{"key" : "value"}' -H "Content-Type: application/json" -X POST http://localhost:5001/users/test/inbox
|
|
|
|
Or, with an actual Mastodon follow request:
|
|
|
|
$ curl -d '{"@context":["https://www.w3.org/ns/activitystreams","https://w3id.org/security/v1",{"manuallyApprovesFollowers":"as:manuallyApprovesFollowers","sensitive":"as:sensitive","movedTo":{"@id":"as:movedTo","@type":"@id"},"Hashtag":"as:Hashtag","ostatus":"http://ostatus.org#","atomUri":"ostatus:atomUri","inReplyToAtomUri":"ostatus:inReplyToAtomUri","conversation":"ostatus:conversation","toot":"http://joinmastodon.org/ns#","Emoji":"toot:Emoji","focalPoint":{"@container":"@list","@id":"toot:focalPoint"},"featured":{"@id":"toot:featured","@type":"@id"},"schema":"http://schema.org#","PropertyValue":"schema:PropertyValue","value":"schema:value"}],"id":"https://post.lurk.org/02d04ed5-dda6-48f3-a551-2e9c554de745","type":"Follow","actor":"https://post.lurk.org/users/manetta","object":"https://ap.virtualprivateserver.space/users/test","signature":{"type":"RsaSignature2017","creator":"https://post.lurk.org/users/manetta#main-key","created":"2018-11-28T16:15:35Z","signatureValue":"XUdBg+Zj9pkdOXlAYHhOtZlmU1Jdt63zwh2cXoJ8E8C1C+KvgGilkyfPTud9VNymVwdUQRl+YEW9KAZiiGaHb9H+tdVUr9BEkuR5E/tGehbMZr1sakC+qPehe4s3bRKEpJjTTJnTiSHaW7V6Qvr1u6+MVts6oj32az/ixuB/CfodSr3K/K+jZmmOl6SIUqX7Xg7xGwOxIsYaR7g9wbcJ4qyzKcTPZonPMsONq9/RSm3SeQBo7WO1FKlQiFxVP/y5eFaFP8GYDLZyK7Nj5kDL5TannfEpuF8f3oyTBErQhcFQYKcBZNbuaqX/WiIaGjtHIL2ctJe0Psb5Nfshx4MXmQ=="}}' -H "Content-Type: application/json" -X POST http://localhost:5001/users/test/inbox
|
|
|
|
"""
|
|
|
|
|
|
|
|
if request.method == 'GET':
|
|
|
|
return '''This has been a <em>{}</em> request. <br>
|
|
|
|
It came with the following header: <br><br><em>{}</em><br><br>
|
|
|
|
You have searched for the actor <em>{}</em>. <br>
|
|
|
|
This is <em>{}</em>'s shared inbox: <br><br><em>{}</em>'''.format(request.method, request.headers, actor,
|
|
|
|
current_app.config['SERVER_NAME'], str(INBOX))
|
|
|
|
|
|
|
|
if request.method == 'POST':
|
|
|
|
INBOX.append(request.data)
|
|
|
|
return Response(status=200)
|