app.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777
  1. # -*- coding: utf-8 -*-
  2. """
  3. (c) 2020 - Copyright ...
  4. Authors:
  5. zPlus <zplus@peers.community>
  6. """
  7. import blinker
  8. import celery
  9. import flask
  10. import functools
  11. import json
  12. import os
  13. import logging
  14. import pagure
  15. import pagure.config
  16. import rdflib
  17. import requests
  18. import requests_http_signature
  19. import urllib
  20. from . import activitypub
  21. from . import graph
  22. from . import model
  23. from . import settings
  24. from . import tasks
  25. log = logging.getLogger(__name__)
  26. log.info('Initializing forgefed plugin...')
  27. # Pagure uses several ways to send out notification about events that have
  28. # occurred within the instance. We use "blinker" and subscribe to the pagure
  29. # signal. See pagure.lib.notify for more info.
  30. blinker.signal('pagure').connect(
  31. lambda sender, topic, message: tasks.notification.handle_pagure_signal.delay(topic, message),
  32. weak=False)
  33. # This Flask Blueprint will be imported by Pagure
  34. APP = flask.Blueprint('forgefed_ns', __name__, url_prefix='/',
  35. template_folder='templates')
  36. # TODO load Blueprint configuration from file
  37. APP.config = {}
  38. # The app URL defined in the Pagure configuration, eg. "https://example.org/"
  39. # We need this for generating IDs.
  40. APP_URL = pagure.config.config['APP_URL'].rstrip('/')
  41. assert APP_URL and len(APP_URL) > 0, 'APP_URL missing from Pagure configuration.'
  42. def get_actor_from_route(path, method='GET'):
  43. """
  44. This is a helper function to return the model.Actor that matches a
  45. given @route path. The motivation for this is that we can avoid writing
  46. the same routes for every Actor. For example we can have a single view
  47. that matches INBOXes instead of having an /inbox view for every Actor.
  48. :param path: The path to match Pagure routes against.
  49. :param method: The HTTP method to match.
  50. :return: An instance of model.Actor.
  51. TODO Replace this function with model.from_uri which is a more general function
  52. that can be used without flask's create_url_adapter().
  53. """
  54. # This works as a reverse Flask url_for function. It matches a string
  55. # against the Werkzeug Routes (defined by the Pagure app), and returns
  56. # the name of the matched endpoint with its arguments. This is more
  57. # convoluted than I like, but Flask doesn't have any function like
  58. # url_for so we need to bind/match using Werkzeug directly.
  59. # https://github.com/pallets/flask/issues/3619
  60. # https://flask.palletsprojects.com/en/1.1.x/api/#flask.Flask.url_map
  61. # https://flask.palletsprojects.com/en/1.1.x/api/#flask.Flask.create_url_adapter
  62. # https://werkzeug.palletsprojects.com/en/1.0.x/routing/#quickstart
  63. try:
  64. # https://flask.palletsprojects.com/en/1.1.x/api/#flask.current_app
  65. #(endpoint, arguments) = flask.current_app.url_map.bind('').match(path, method)
  66. (endpoint, arguments) = flask.current_app.create_url_adapter(flask.request).match(path, method)
  67. except Exception:
  68. return None
  69. if endpoint == 'ui_ns.view_user':
  70. return flask.g.session \
  71. .query(model.Person) \
  72. .filter(model.Person.user == arguments['username']) \
  73. .one_or_none()
  74. if endpoint == 'ui_ns.view_repo_git':
  75. actor = flask.g.session \
  76. .query(model.Repository) \
  77. .filter(model.Repository.name == arguments['repo'])
  78. if 'username' in arguments:
  79. actor = actor.filter(model.Repository.is_fork == True) \
  80. .filter(model.Repository.user.has(model.Person.user == arguments['username']))
  81. else:
  82. actor = actor.filter(model.Repository.is_fork == False)
  83. if 'namespace' in arguments:
  84. actor = actor.filter(model.Repository.namespace == arguments['namespace'])
  85. return actor.one_or_none()
  86. if endpoint in [ 'ui_ns.view_issue', 'ui_ns.view_repo' ]:
  87. actor = flask.g.session \
  88. .query(model.Projects) \
  89. .filter(model.Projects.name == arguments['repo'])
  90. if 'username' in arguments:
  91. actor = actor.filter(model.Projects.is_fork == True) \
  92. .filter(model.Projects.user.has(model.Person.user == arguments['username']))
  93. else:
  94. actor = actor.filter(model.Projects.is_fork == False)
  95. if 'namespace' in arguments:
  96. actor = actor.filter(model.Projects.namespace == arguments['namespace'])
  97. return actor.one_or_none()
  98. def requires_login(func):
  99. """
  100. A decorator for routes to check user login.
  101. """
  102. @functools.wraps(func)
  103. def decorator(*args, **kwargs):
  104. if not flask.g.authenticated:
  105. return ("", 401) # Unauthorized
  106. return func(*args, **kwargs)
  107. return decorator
  108. @APP.after_request
  109. def add_header(response):
  110. """
  111. Automatically set Content-Type header to all the Blueprint responses.
  112. # TODO Untangle this!
  113. """
  114. # Return default headers
  115. if flask.request.path.startswith('/federation'):
  116. return response
  117. if flask.request.path.startswith('/.well-known/host-meta'):
  118. response.headers['Content-Type'] = 'application/xrd+xml; charset=utf-8'
  119. elif flask.request.path.startswith('/.well-known/webfinger'):
  120. response.headers['Content-Type'] = 'application/jrd+json; charset=utf-8'
  121. else:
  122. response.headers['Content-Type'] = activitypub.default_header
  123. return response
  124. @APP.record
  125. def override_pagure_routes(setup_state):
  126. """
  127. We reuse Pagure routes in order to return ActivityPub objects in response
  128. to "Accept: application/ld+json" request headers. The "record" decorator
  129. registers a callback function that is called during initialization of the
  130. Blueprint by Flask. While Flask offers the app context during requests
  131. handling, the same context is not available during initialization.
  132. Therefore we need this callback which is called during Flask initialization
  133. in order to get the app context that we need to replace the Pagure views.
  134. See https://flask.palletsprojects.com/en/1.1.x/api/#flask.Blueprint.record
  135. for more info about the record() function/decorator.
  136. NOTE - If Flask accepted route dispatching based on headers value, we could
  137. just use something like @APP.route(accept='application/activity+json').
  138. But it does not, so we need to override the Pagure views.
  139. - ActivityPub requires some routes to exist for Actors, for example
  140. "inbox" and "followers". However, Pagure does not have suitable
  141. routes that we can reuse for this purpose. For this reason, those
  142. routes are defined directly on the Blueprint.
  143. """
  144. # Reference to the main Flask app created by Pagure and to which Blueprints
  145. # will be attached.
  146. pagure_app = setup_state.app
  147. # We add our templates folder to the app's jinja path, such that we can
  148. # override pagure templates.
  149. pagure_app.jinja_loader \
  150. .searchpath \
  151. .insert(0, os.path.join(os.path.abspath(os.path.dirname(__file__)), 'templates'))
  152. # Create a symlink to pagure templates folder. This is only used when
  153. # "extending" templates using {% extends "master.html" %}, because extending
  154. # a template with the same name will trigger an infinite recursion.
  155. pagure_path_symlink = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'templates/__pagure__')
  156. if not os.path.islink(pagure_path_symlink):
  157. os.symlink(os.path.join(settings.PAGURE_PATH, 'pagure/templates/'), pagure_path_symlink)
  158. @pagure_app.before_request
  159. def start_database_session():
  160. """
  161. At the beginning of every request we get a new connection to the
  162. forgefed graph store. Please note that this function is executed
  163. before *every* request, for every route, including the ones
  164. defined in the Blueprint.
  165. """
  166. flask.g.forgefed_graph = graph.Graph()
  167. """
  168. @pagure_app.after_request
  169. def do_something(response):
  170. return response
  171. """
  172. @pagure_app.teardown_request
  173. def free_database_session(exception=None):
  174. """
  175. Close and remove the database session that was initiated in
  176. @before_requrest. This instruction should be optional since the object
  177. should be automatically garbage-collected when the request is destroyed.
  178. """
  179. flask.g.forgefed_graph.commit()
  180. flask.g.forgefed_graph.disconnect()
  181. def pagure_route(endpoint):
  182. """
  183. This function returns a decorator whose job is to replace a Pagure
  184. view with another one that will check the HTTP "Accept" header. If the
  185. HTTP request is a ActivityPub one, we respond appropriately. Otherwise
  186. we just return control to the Pagure view.
  187. Additional documentation useful for this decorator: https://flask.palletsprojects.com/en/1.1.x/api/#flask.Flask.view_functions
  188. """
  189. def decorator(forgefed_view):
  190. # The Flask object "pagure_app.view_functions" contains all the
  191. # views defined by Pagure.
  192. pagure_view = pagure_app.view_functions[endpoint]
  193. # https://docs.python.org/3.9/library/functools.html#functools.wraps
  194. @functools.wraps(forgefed_view)
  195. def wrapper(*args, **kwargs):
  196. if 'Accept' in flask.request.headers:
  197. # HTTP headers can contain multiple values separated by a comma
  198. request_headers = [
  199. value.strip(' ')
  200. for value
  201. in flask.request.headers.get('Accept').split(',') ]
  202. if any(header in activitypub.headers for header in request_headers):
  203. response = flask.make_response(forgefed_view(*args, **kwargs))
  204. response.headers['Content-Type'] = activitypub.default_header
  205. return response
  206. # If it's not an ActivityPub request, just fall through to the
  207. # Pagure default view.
  208. return pagure_view(*args, **kwargs)
  209. # Replace the pagure view with our own
  210. pagure_app.view_functions[endpoint] = wrapper
  211. return wrapper
  212. return decorator
  213. ###########################################################################
  214. # Person
  215. ###########################################################################
  216. @pagure_route('ui_ns.view_user')
  217. def person(*args, **kwargs):
  218. """
  219. Return a Person object from the Pagure user page.
  220. """
  221. actor = get_actor_from_route(flask.request.path, 'GET')
  222. if not actor:
  223. return ({}, 404)
  224. return actor.jsonld
  225. ###########################################################################
  226. # Repository
  227. #
  228. # - Pagure uses 4 kind of URLs for repositories.
  229. # - The Pagure app defines a Flask before_request() function that does a
  230. # lot of things, among which to check if there is a "repo" variable in
  231. # the URL and in turn setup some context for the request. This happens
  232. # for *every* request, regardless if it's about a repository or not. The
  233. # rationale is that they rather do it this way than using a separate
  234. # decorator for all the repositories views, because almost all requests
  235. # are about repositories anyway. before_request() will then automatically
  236. # return 404 if a repository does not exist, so we don't need to check
  237. # that here, unlike what we did in person(*args, **kwargs), because these
  238. # views will never be executed.
  239. # - The Pagure before_request() retrieves the repository using the function
  240. # pagure.lib.query.get_authorized_project() which checks for
  241. # authorization and, if the repo is private, it returns 404. So there
  242. # should be no need to check for authorization here.
  243. ###########################################################################
  244. @pagure_route('ui_ns.view_repo_git')
  245. def repository(*args, **kwargs):
  246. """
  247. Return a Repository object from the project page.
  248. """
  249. actor = get_actor_from_route(flask.request.path, 'GET')
  250. if not actor:
  251. return ({}, 404)
  252. return actor.jsonld
  253. ###########################################################################
  254. # Project
  255. ###########################################################################
  256. @pagure_route('ui_ns.view_repo')
  257. def project(*args, **kwargs):
  258. """
  259. Return a Project object from the project page.
  260. """
  261. repository = get_actor_from_route(flask.request.path, 'GET')
  262. if not repository:
  263. return ({}, 404)
  264. project = flask.g.session \
  265. .query(model.Projects) \
  266. .filter(model.Projects.id == repository.id) \
  267. .one_or_none()
  268. return project.jsonld
  269. ###########################################################################
  270. # Tickets
  271. ###########################################################################
  272. @pagure_route('ui_ns.view_issue')
  273. def ticket(*args, **kwargs):
  274. username = kwargs.get('username')
  275. namespace = kwargs.get('namespace')
  276. name = kwargs.get('repo')
  277. issue_id = kwargs.get('issueid')
  278. repository = get_actor_from_route(flask.request.path, 'GET')
  279. if not repository:
  280. return ({}, 404)
  281. ticket = flask.g.session \
  282. .query(model.Ticket) \
  283. .filter(model.Ticket.id == issue_id,
  284. model.Ticket.project_id == repository.id) \
  285. .one_or_none()
  286. if not ticket:
  287. return ({}, 404)
  288. return ticket.jsonld
  289. log.info('forgefed plugin registered by Flask.')
  290. ###############################################################################
  291. # WebFinger
  292. #
  293. # This is primarily used to support other ActivityPub software such as
  294. # Mastodon that relies on webfinger as a discovery protocol because users
  295. # use @username@domain when mentioning other users.
  296. # https://docs.joinmastodon.org/spec/webfinger/
  297. ###############################################################################
  298. @APP.route('/.well-known/host-meta', methods=['GET'])
  299. def host_meta():
  300. return flask.render_template('host-meta.xml', APP_URL=APP_URL)
  301. @APP.route('/.well-known/webfinger/<path:uri>', methods=['GET'])
  302. def webfinger_resource(uri):
  303. """
  304. Return the webfinger info for account "uri".
  305. :param uri: The "acct:userpard@host" to search.
  306. """
  307. # Only support acct: resources
  308. # Do we need to support other schemes? WebFinger is neutral regarding the
  309. # scheme of URI: it could be "acct", "http", "https", "mailto", or some
  310. # other scheme, but other AcitivityPub instances such as Mastodon only
  311. # use "acct".
  312. if not uri.startswith('acct:'):
  313. return ({}, 404)
  314. # The "acct" scheme is defined in the spec as
  315. # "acct" ":" userpart "@" host
  316. # "host" is the domain where the account is hosted
  317. # "userpart" contains the localinfo used by the host to retrieve the account
  318. userpart, host = uri[5:].rsplit('@', 1)
  319. # Now we find the actual actor's URI.
  320. # The "userpart" is basically the "preferredUsername" property defined in
  321. # model.py
  322. if userpart.startswith('project/'):
  323. actor_uri = '{}/{}'.format(APP_URL, userpart[8:])
  324. else:
  325. actor_uri = '{}/user/{}'.format(APP_URL, userpart)
  326. return flask.render_template('webfinger.json', subject=uri, actor_uri=actor_uri)
  327. ###############################################################################
  328. # Routes used to interact with remote objects of the federation, when we
  329. # cannot reuse another pagure route.
  330. ###############################################################################
  331. @APP.route('/federation', methods=['GET'])
  332. @requires_login
  333. def federation():
  334. user = flask.g.fas_user
  335. return flask.render_template(
  336. 'federation/feed.html'
  337. )
  338. @APP.route('/federation/followers', methods=['GET'])
  339. @requires_login
  340. def federation_followers():
  341. user = flask.g.fas_user
  342. return flask.render_template(
  343. 'federation/followers.html'
  344. )
  345. @APP.route('/federation/following', methods=['GET'])
  346. @requires_login
  347. def federation_following():
  348. person = flask.g.session \
  349. .query(model.Person) \
  350. .filter(model.Person.id == flask.g.fas_user.id) \
  351. .one_or_none()
  352. if not person:
  353. return flask.redirect(url_for('forgefed_ns.federation'))
  354. # Retrieve "following" list from the local graph
  355. collection = flask.g.forgefed_graph.get_collection(person.following_uri)
  356. collection_page = flask.g.forgefed_graph.get_collection(collection['current'])
  357. following = []
  358. for actor in collection_page['orderedItems']:
  359. following.append(flask.g.forgefed_graph.get_json_node(actor))
  360. return flask.render_template('federation/following.html', following=following)
  361. @APP.route('/federation/search', methods=['GET', 'POST'])
  362. @requires_login
  363. def federation_search():
  364. uri = flask.request.args.get('uri')
  365. search_result = None
  366. # Search for an object
  367. if uri:
  368. search_result = activitypub.fetch(uri)
  369. return flask.render_template(
  370. 'federation/search.html',
  371. uri=uri,
  372. search_result=search_result)
  373. @APP.route('/federation/follow', methods=['GET'])
  374. @requires_login
  375. def federation_follow():
  376. remote_actor_uri = flask.request.args.get('actor_uri')
  377. remote_actor = activitypub.fetch(remote_actor_uri)
  378. if not remote_actor:
  379. return 'Could not fetch remote actor.'
  380. # The user that clicked the "Follow" button
  381. person = flask.g.session \
  382. .query(model.Person) \
  383. .filter(model.Person.id == flask.g.fas_user.id) \
  384. .one_or_none()
  385. if not remote_actor_uri or not person:
  386. return flask.redirect(url_for('forgefed_ns.federation'))
  387. # Send the Activity
  388. person.follow(object_uri=remote_actor_uri, to=remote_actor_uri)
  389. # We also save a copy of the remote actor, for easy listing in the
  390. # "following/followers" collections
  391. flask.g.forgefed_graph.parse(data=json.dumps(remote_actor), format='json-ld')
  392. # Add the remote actor to the "following" collection
  393. if not flask.g.forgefed_graph.collection_contains(person.following_uri, remote_actor_uri):
  394. flask.g.forgefed_graph.add_collection_item(person.following_uri, remote_actor_uri)
  395. return flask.redirect(flask.url_for('forgefed_ns.federation_following'))
  396. @APP.route('/federation/submit_ticket', methods=['GET'])
  397. @requires_login
  398. def federation_submit_ticket():
  399. # The URL of the remote tracker (actor)
  400. actor_uri = flask.request.args.get('actor_uri')
  401. if not actor_uri:
  402. return flask.redirect(flask.url_for('ui_ns.index'))
  403. # Retrieve the remote actor
  404. actor = activitypub.fetch(actor_uri)
  405. # Create a local user for the project because in Pagure a project must
  406. # have a user ID
  407. user = model.test_or_set_remote_actor(
  408. pagure_db=flask.g.session,
  409. forgefed_graph=flask.g.forgefed_graph,
  410. uri=actor_uri)
  411. project = flask.g.session \
  412. .query(model.Projects) \
  413. .filter(model.Projects.name == user.username) \
  414. .one_or_none()
  415. # Check if we already have a local tracker for contributing to the remote
  416. # tracker. If not, created it.
  417. if not project:
  418. # Create the project in the Pagure database.
  419. # This function will create the item in the database, and then will
  420. # start a new async task to create the actual .git folder.
  421. pagure.lib.query.new_project(
  422. flask.g.session,
  423. user=user.username,
  424. name=user.username,
  425. blacklist=[],
  426. allowed_prefix=[],
  427. repospanner_region=None,
  428. description="Remote",
  429. url=actor_uri,
  430. avatar_email=None,
  431. parent_id=None,
  432. add_readme=False,
  433. mirrored_from=None,
  434. userobj=user,
  435. prevent_40_chars=False,
  436. namespace=None,
  437. user_ns=False,
  438. ignore_existing_repo=False,
  439. private=False,
  440. )
  441. # The tracker that we've just created is only used to interact with a
  442. # remote one. It's not a "real" local tracker by a local user, so we
  443. # create a owl:sameAs relation in the graph.
  444. tracker_url = '{}/{}/issues'.format(APP_URL, user.username)
  445. flask.g.forgefed_graph.set((rdflib.URIRef(tracker_url),
  446. rdflib.OWL.sameAs,
  447. rdflib.URIRef(actor_uri)))
  448. # Redirect to the "new issue" page of the local tracker, where the user can
  449. # create a new issue for the remote tracker.
  450. return flask.redirect(flask.url_for('ui_ns.new_issue', repo=user.username))
  451. @APP.route('/federation/ticket/<issue_uid>/comments', methods=['GET'])
  452. def federation_ticket_comments(issue_uid):
  453. """
  454. Return the Collection containing a ticket's comments.
  455. This route exists because pagure does not have any route defined for
  456. comments. The path of a comment in pagure is "/<repo>/issue/<issueid>#comment-<commend_id>"
  457. but the trailing part of the URL after the # symbol is not sent to the server.
  458. :param issue_uid: The unique ID defined by pagure during ticket creation. This
  459. is used instead of the default key which is (project_id, issue_id).
  460. :param page: The page of the collection to show.
  461. """
  462. collection_uri = APP_URL + flask.request.path
  463. flask.g.forgefed_graph.test_or_set_ordered_collection(collection_uri)
  464. return flask.g.forgefed_graph.get_collection(collection_uri)
  465. @APP.route('/federation/ticket/<issue_uid>/comments/<int:page>', methods=['GET'])
  466. def federation_ticket_comments_page(issue_uid):
  467. """
  468. Return the CollectionPage containing a ticket's comments.
  469. :param issue_uid: The unique ID defined by pagure during ticket creation. This
  470. is used instead of the default key which is (project_id, issue_id).
  471. :param page: The page of the collection to show.
  472. """
  473. collection_uri = APP_URL + flask.request.path
  474. return flask.g.forgefed_graph.get_collection(collection_uri)
  475. @APP.route('/federation/ticket_comment/<comment_id>', methods=['GET'])
  476. def federation_ticket_comment(comment_id):
  477. """
  478. Return the JSONLD of a ticket comment.
  479. :param comment_id: The comment ID defined by pagure during comment creation.
  480. This is the default primary key of the comment and is unique across all
  481. local issues.
  482. """
  483. return flask.g.session \
  484. .query(model.TicketComment) \
  485. .filter(model.TicketComment.id == comment_id) \
  486. .one_or_none() \
  487. .jsonld
  488. ###############################################################################
  489. # Actor
  490. ###############################################################################
  491. @APP.route('/<path:actor>/key.pub', methods=['GET'])
  492. def actor_key(actor):
  493. """
  494. This object represents the public GPG key used to sign HTTP requests.
  495. """
  496. actor_path = '/' + actor
  497. actor_uri = APP_URL + '/' + actor
  498. actor = get_actor_from_route(actor_path, 'GET')
  499. if not actor:
  500. return ({}, 404)
  501. # Test if this key exists, otherwise create a new one
  502. flask.g.forgefed_graph.test_or_set_key(actor.local_uri, actor.publickey_uri)
  503. # Retrieve the graph node of the key
  504. node = flask.g.forgefed_graph \
  505. .subgraph((rdflib.URIRef(actor.publickey_uri), None, None))
  506. # Remove the private key before displaying
  507. node.remove((None, graph.SEC.privateKeyPem, None))
  508. # TODO do not replace CryptographicKey with Key. This is only here because
  509. # vervis is giving an error
  510. key = json.loads(node.serialize(context=activitypub.cached_jsonld_context,
  511. format='json-ld'))
  512. key['type'] = 'Key'
  513. return key
  514. @APP.route('/<path:actor>/inbox', methods=['GET'])
  515. def actor_inbox(actor):
  516. """
  517. Returns an Actor's INBOX.
  518. This should only be called by the Actors who want to read their own INBOX.
  519. """
  520. return ({}, 501) # 501 Not Implemented. TODO implement C2S.
  521. @APP.route('/<path:actor>/outbox', methods=['GET'])
  522. def actor_outbox(actor, page=None):
  523. """
  524. Returns an Actor's OUTBOX.
  525. This should only be called by the Actors who want to read their own OUTBOX.
  526. """
  527. # TODO Show only "Public" OUTBOX
  528. # https://www.w3.org/TR/activitypub/#public-addressing
  529. return ({}, 501) # 501 Not Implemented. TODO implement C2S.
  530. @APP.route('/<path:actor>/inbox', methods=['POST'])
  531. def actor_receive(actor):
  532. """
  533. Somebody is sending a message to an actor's INBOX.
  534. """
  535. # TODO
  536. # Verify incoming request signature. Check if the HTTP POST request is
  537. # signed correctly.
  538. """
  539. def key_resolver(key_id, algorithm):
  540. return remote actor public key
  541. try:
  542. requests_http_signature.HTTPSignatureAuth.verify(
  543. flask.request,
  544. key_resolver=key_resolver)
  545. except Exception:
  546. return ({}, 401)
  547. """
  548. actor_path = '/' + actor
  549. actor_uri = APP_URL + '/' + actor
  550. actor = get_actor_from_route(actor_path, 'GET')
  551. if not actor:
  552. return ({}, 404)
  553. # Retrieve the ActivityPub Activity from the HTTP request body. The
  554. # Activity is expected to be a JSON object.
  555. # https://flask.palletsprojects.com/en/1.1.x/api/#flask.Request.get_json
  556. activity = flask.request.get_json()
  557. # Schedule a task to process the incoming activity asynchronously
  558. tasks.activity.validate.delay(actor.__repr__(), activity)
  559. return ({}, 202) # 202 Accepted
  560. @APP.route('/<path:actor>/outbox', methods=['POST'])
  561. def actor_send(actor):
  562. """
  563. An Actor is trying to POST an Activity to its OUTBOX.
  564. This should only be called by an Actor's client that want to send out a new
  565. Activity.
  566. """
  567. return ({}, 501) # 501 Not Implemented. TODO implement C2S.
  568. @APP.route('/<path:actor>/followers', methods=['GET'])
  569. def actor_followers(actor):
  570. """
  571. Show the followers of an actor.
  572. """
  573. actor_path = '/' + actor
  574. actor_uri = APP_URL + '/' + actor
  575. collection_uri = APP_URL + flask.request.path
  576. actor = get_actor_from_route(actor_path, 'GET')
  577. if not actor:
  578. return ({}, 404)
  579. flask.g.forgefed_graph.test_or_set_ordered_collection(collection_uri)
  580. return flask.g.forgefed_graph.get_collection(collection_uri)
  581. @APP.route('/<path:actor>/followers/<int:page>', methods=['GET'])
  582. def actor_followers_page(actor, page):
  583. """
  584. Show the followers of an actor.
  585. """
  586. collection_uri = APP_URL + flask.request.path
  587. return flask.g.forgefed_graph.get_collection(collection_uri)
  588. @APP.route('/<path:actor>/following', methods=['GET'])
  589. def actor_following(actor):
  590. """
  591. Show the actors that an actor is following.
  592. """
  593. actor_path = '/' + actor
  594. actor_uri = APP_URL + '/' + actor
  595. collection_uri = APP_URL + flask.request.path
  596. actor = get_actor_from_route(actor_path, 'GET')
  597. if not actor:
  598. return ({}, 404)
  599. flask.g.forgefed_graph.test_or_set_ordered_collection(collection_uri)
  600. if page:
  601. collection_uri += '/' + str(page)
  602. return flask.g.forgefed_graph.get_collection(collection_uri)
  603. @APP.route('/<path:actor>/following/<int:page>', methods=['GET'])
  604. def actor_following_page(actor, page):
  605. """
  606. Show the actors that an actor is following.
  607. """
  608. collection_uri = APP_URL + flask.request.path
  609. return flask.g.forgefed_graph.get_collection(collection_uri)
  610. log.info('forgefed plugin initialized.')