app.py 31 KB

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