diff --git a/app/activitypub/__init__.py b/app/activitypub/__init__.py new file mode 100644 index 00000000..037c533a --- /dev/null +++ b/app/activitypub/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +bp = Blueprint('activitypub', __name__) + +from app.activitypub import routes diff --git a/app/activitypub/routes.py b/app/activitypub/routes.py new file mode 100644 index 00000000..79d0f0da --- /dev/null +++ b/app/activitypub/routes.py @@ -0,0 +1,133 @@ +from sqlalchemy import text + +from app import db +from app.activitypub import bp +from flask import request, Response, render_template, current_app, abort, jsonify +from app.models import User, Community +from app.activitypub.util import public_key + +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(): + users_total = db.session.execute(text('SELECT COUNT(id) as c FROM "user" WHERE ap_id is null AND verified is true AND banned is false AND deleted is false')).scalar() + active_half_year = db.session.execute(text("SELECT COUNT(id) as c FROM \"user\" WHERE last_seen >= CURRENT_DATE - INTERVAL '6 months' AND ap_id is null AND verified is true AND banned is false AND deleted is false")).scalar() + active_month = db.session.execute(text("SELECT COUNT(id) as c FROM \"user\" WHERE last_seen >= CURRENT_DATE - INTERVAL '1 month' AND ap_id is null AND verified is true AND banned is false AND deleted is false")).scalar() + local_posts = db.session.execute(text('SELECT COUNT(id) as c FROM "post" WHERE ap_id is null')).scalar() + local_comments = db.session.execute(text('SELECT COUNT(id) as c FROM "post_reply" WHERE ap_id is null')).scalar() + + nodeinfo_data = { + "version": "2.0", + "software": { + "name": "pyfedi", + "version": "0.1" + }, + "protocols": [ + "activitypub" + ], + "usage": { + "users": { + "total": users_total, + "activeHalfyear": active_half_year, + "activeMonth": active_month + }, + "localPosts": local_posts, + "localComments": local_comments + }, + "openRegistrations": True + } + return jsonify(nodeinfo_data) + + +@bp.route('/users/', methods=['GET']) +def return_actor(actor): + """ This returns the actor.json object when somebody in the + Fediverse searches for this user. It returns the paths of + this user's inbox, its preferred username, the user's public key + and a profile image """ + + preferredUsername = actor # but could become a custom username, set by the user, stored in the database this preferredUsername doesn't show up yet in Mastodon ... + json = render_template('actor.json', actor=actor, preferredUsername=preferredUsername, publicKey=public_key(), + domain=current_app.config['SERVER_NAME']) # actor = alice + resp = Response(json, status=200, mimetype='application/json') + return resp + + +@bp.route('/inspect') +def inspect(): + return Response(b'

'.join(INBOX), status=200) + + +@bp.route('/users//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 {} request.
+ It came with the following header:

{}

+ You have searched for the actor {}.
+ This is {}'s shared inbox:

{}'''.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) diff --git a/app/activitypub/util.py b/app/activitypub/util.py new file mode 100644 index 00000000..9e6833eb --- /dev/null +++ b/app/activitypub/util.py @@ -0,0 +1,12 @@ +import os + + +def public_key(): + if not os.path.exists('./public.pem'): + os.system('openssl genrsa -out private.pem 2048') + os.system('openssl rsa -in private.pem -outform PEM -pubout -out public.pem') + else: + publicKey = open('./public.pem', 'r').read() + PUBLICKEY = publicKey.replace('\n', '\\n') # JSON-LD doesn't want to work with linebreaks, + # but needs the \n character to know where to break the line ;) + return PUBLICKEY