app.py 56 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614
  1. """
  2. ForgeFed plugin for Pagure.
  3. Copyright (C) 2020-2021 zPlus <zplus@peers.community>
  4. This program is free software; you can redistribute it and/or modify
  5. it under the terms of the GNU General Public License as published by
  6. the Free Software Foundation; either version 2 of the License, or
  7. (at your option) any later version.
  8. This program is distributed in the hope that it will be useful,
  9. but WITHOUT ANY WARRANTY; without even the implied warranty of
  10. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  11. GNU General Public License for more details.
  12. You should have received a copy of the GNU General Public License along
  13. with this program; if not, see <https://www.gnu.org/licenses/>.
  14. SPDX-FileCopyrightText: 2020-2021 zPlus <zplus@peers.community>
  15. SPDX-License-Identifier: GPL-2.0-only
  16. """
  17. import celery
  18. import flask
  19. import functools
  20. import json
  21. import os
  22. import logging
  23. import pagure
  24. import pagure.config
  25. import pagure.lib.git
  26. import pygit2
  27. import rdflib
  28. import requests
  29. import requests_http_signature
  30. import sqlalchemy
  31. import traceback
  32. import urllib
  33. from . import APP_URL
  34. from . import activitypub
  35. from . import feeds
  36. from . import model
  37. from . import settings
  38. from . import tasks
  39. log = logging.getLogger(__name__)
  40. log.debug('Initializing forgefed plugin...')
  41. # This Flask Blueprint will be imported by Pagure
  42. APP = flask.Blueprint('forgefed_ns', __name__, url_prefix='/',
  43. template_folder='templates')
  44. # TODO load Blueprint configuration from file if needed
  45. APP.config = {}
  46. def requires_login(func):
  47. """
  48. A decorator for routes to check user login.
  49. """
  50. @functools.wraps(func)
  51. def decorator(*args, **kwargs):
  52. if not flask.g.authenticated:
  53. return flask.redirect('/')
  54. # return ("", 401) # Unauthorized
  55. return func(*args, **kwargs)
  56. return decorator
  57. @APP.after_request
  58. def add_header(response):
  59. """
  60. Automatically set Content-Type header to all the Blueprint responses.
  61. # TODO Untangle this! Make it nicer.
  62. """
  63. # Return default headers
  64. if flask.request.path.startswith('/federation'):
  65. return response
  66. content_type = None
  67. if flask.request.path.startswith('/.well-known/host-meta'):
  68. content_type = 'application/xrd+xml; charset=utf-8'
  69. elif flask.request.path.startswith('/.well-known/webfinger'):
  70. content_type = 'application/jrd+json; charset=utf-8'
  71. elif flask.request.path.startswith('/.well-known/nodeinfo'):
  72. content_type = 'application/jrd+json; charset=utf-8'
  73. elif flask.request.path == '/nodeinfo/2.1':
  74. content_type = 'application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.1#"'
  75. else:
  76. content_type = activitypub.default_header
  77. response.headers['Content-Type'] = content_type
  78. return response
  79. @APP.record
  80. def override_pagure_routes(setup_state):
  81. """
  82. We reuse Pagure routes in order to return ActivityPub objects in response
  83. to "Accept: application/ld+json" request headers. The "record" decorator
  84. registers a callback function that is called during initialization of the
  85. Blueprint by Flask. While Flask offers the app context during requests
  86. handling, the same context is not available during initialization.
  87. Therefore we need this callback which is called during Flask initialization
  88. in order to get the app context that we need to replace the Pagure views.
  89. See https://flask.palletsprojects.com/en/1.1.x/api/#flask.Blueprint.record
  90. for more info about the record() function/decorator.
  91. NOTE - If Flask accepted route dispatching based on headers value, we could
  92. just use something like @APP.route(accept='application/activity+json').
  93. But it does not, so we need to override the Pagure views.
  94. - ActivityPub requires some routes to exist for Actors, for example
  95. "inbox" and "followers". However, Pagure does not have suitable
  96. routes that we can reuse for this purpose. For this reason, those
  97. routes are defined directly on the Blueprint.
  98. """
  99. # Reference to the main Flask app created by Pagure and to which Blueprints
  100. # will be attached.
  101. pagure_app = setup_state.app
  102. # We add our templates folder to the app's jinja path, such that we can
  103. # override pagure templates.
  104. pagure_app.jinja_loader \
  105. .searchpath \
  106. .insert(0, os.path.join(os.path.abspath(os.path.dirname(__file__)), 'templates'))
  107. # Create a symlink to pagure templates folder. This is only used when
  108. # "extending" templates using {% extends "master.html" %}, because extending
  109. # a template with the same name will trigger an infinite recursion.
  110. pagure_path_symlink = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'templates/__pagure__')
  111. if not os.path.islink(pagure_path_symlink):
  112. os.symlink(os.path.join(settings.PAGURE_PATH, 'pagure/templates/'), pagure_path_symlink)
  113. """
  114. DEPRECATED using Pagure own database instead
  115. @pagure_app.before_request
  116. def start_database_session():
  117. # At the beginning of every request we get a new connection to the
  118. # forgefed graph store. Please note that this function is executed
  119. # before *every* request, for every route, including the ones
  120. # defined in the Blueprint.
  121. flask.g.forgefed = database.start_database_session()
  122. @pagure_app.after_request
  123. def do_something(response):
  124. return response
  125. @pagure_app.teardown_request
  126. def free_database_session(exception=None):
  127. # Close and remove the database session that was initiated in
  128. # @before_requrest. This instruction should be optional since the object
  129. # should be automatically garbage-collected when the request is destroyed.
  130. flask.g.forgefed.commit()
  131. flask.g.forgefed.remove()
  132. """
  133. def pagure_route(endpoint):
  134. """
  135. This function returns a decorator whose job is to replace a Pagure
  136. view with another one that will check the HTTP "Accept" header. If the
  137. HTTP request is a ActivityPub one the ForgeFed plugin will take care of it,
  138. otherwise it will just pass through the control to the Pagure view.
  139. Additional documentation useful for this decorator: https://flask.palletsprojects.com/en/1.1.x/api/#flask.Flask.view_functions
  140. """
  141. def decorator(forgefed_view):
  142. # The Flask object "pagure_app.view_functions" contains all the
  143. # views defined by Pagure.
  144. pagure_view = pagure_app.view_functions[endpoint]
  145. # https://docs.python.org/3.9/library/functools.html#functools.wraps
  146. @functools.wraps(forgefed_view)
  147. def wrapper(*args, **kwargs):
  148. """
  149. DEPRECATED This is the old code that was used to parse the "Accept"
  150. header manually. It's since been replaced with
  151. flask.request.accept_mimetypes.best. This comment is only
  152. kept here temporarily just in case there are problems with the
  153. new code.
  154. # HTTP headers can contain multiple values separated by a comma
  155. accept_mimetypes = [
  156. value.strip(' ')
  157. for value
  158. in flask.request.accept_mimetypes.best ]
  159. if any(header in activitypub.headers for header in accept_mimetypes):
  160. response = flask.make_response(forgefed_view(*args, **kwargs))
  161. response.headers['Content-Type'] = activitypub.default_header
  162. return response
  163. """
  164. if flask.request.accept_mimetypes.best in activitypub.headers:
  165. response = flask.make_response(forgefed_view(*args, **kwargs))
  166. response.headers['Content-Type'] = activitypub.default_header
  167. return response
  168. # If it's not an ActivityPub request, just fall through to the
  169. # Pagure default view.
  170. return pagure_view(*args, **kwargs)
  171. # Replace the pagure view with our own
  172. pagure_app.view_functions[endpoint] = wrapper
  173. return wrapper
  174. return decorator
  175. ###########################################################################
  176. # ActivityPub Objects
  177. ###########################################################################
  178. @pagure_route('ui_ns.view_user')
  179. def person(*args, **kwargs):
  180. """
  181. Return a Person object from the Pagure user page.
  182. """
  183. # Retrieve path arguments
  184. username = kwargs.get('username')
  185. actor = flask.g.session \
  186. .query(pagure.lib.model.User) \
  187. .filter(pagure.lib.model.User.user == username) \
  188. .one_or_none()
  189. if not actor:
  190. return ({}, 404)
  191. return activitypub.Person(actor=actor)
  192. @pagure_route('ui_ns.view_group')
  193. def group(*args, **kwargs):
  194. """
  195. Return a Pagure Group object.
  196. """
  197. # Retrieve path arguments
  198. group = kwargs.get('group')
  199. group = pagure.lib.query.search_groups(
  200. flask.g.session, group_name=group, group_type="user")
  201. if not group:
  202. return ({}, 404)
  203. return activitypub.Group(group=group)
  204. @pagure_route('ui_ns.view_repo')
  205. def project(*args, **kwargs):
  206. """
  207. Return a Project object from the project page.
  208. """
  209. # Retrieve path arguments
  210. username = kwargs.get('username')
  211. repo = kwargs.get('repo')
  212. namespace = kwargs.get('namespace')
  213. repository = pagure.lib.query.get_authorized_project(
  214. flask.g.session, repo, user=username, namespace=namespace)
  215. project = flask.g.session.query(model.Project) \
  216. .filter(model.Project.id == repository.id) \
  217. .one_or_none()
  218. if not project:
  219. return ({}, 404)
  220. return activitypub.Project(project=project)
  221. @pagure_route('ui_ns.view_repo_git')
  222. @APP.route('/forks/<username>/<repo>.git')
  223. @APP.route('/forks/<username>/<namespace>/<repo>.git')
  224. def repository(repo, username=None, namespace=None, *args, **kwargs):
  225. """
  226. Return a Repository actor object.
  227. - Pagure has a route called "ui_ns.view_repo_git" that doesn't really do
  228. anything except redirect to the project URL (without ".git"). This URL
  229. is also used for GIT clone/push via HTTP. When a person types
  230. "git clone https://..." the GIT protocols appends some query arguments an
  231. these requests are proxied to the GIT backend. The backend uses the request
  232. path to match a repository on the filesystem. For example
  233. https://example.org/project1.git will match the folder $GIT_PROJECT_ROOT/project1.git.
  234. This works for projects (which in Pagure terminology are all the non-fork
  235. repositories) but it's broken for forks. The reason why it's broken is
  236. because all the forks are under the route "/fork/<username>/<projectname>.git"
  237. but the actual folder in the filesystem is "/forks/<username>/<projectname>.git".
  238. "/forks/<username>/<repo>" doesn't match any route in Pagure, but we need it
  239. for ForgeFed because we want the actual clonable URL to be also the Actor URL.
  240. Unfortunately this is not a priority for Pagure devs, and they're not gonna
  241. fix this "fork/forks" issue anytime soon. This is the reason why we need the
  242. extra routes, to match the actual clonable URL of the repository.
  243. NOTE This function's signature and routes are identical to the Pagure
  244. "ui_ns.view_repo_git", with the only exception that "/forks" has been
  245. added.
  246. TODO If they ever fix this problem, this route should be replaced with
  247. simply @pagure_route('ui_ns.view_repo_git')
  248. - The Pagure app defines a Flask before_request() function that does a
  249. lot of things, among which to check if there is a "repo" variable in
  250. the URL and in turn setup some context for the request. This happens
  251. for *every* request, regardless if it's about a repository or not. The
  252. rationale is that they rather do it this way than using a separate
  253. decorator for all the repositories views, because almost all requests
  254. are about repositories anyway. before_request() will then automatically
  255. return 404 if a repository does not exist, so we don't need to check
  256. that here because these views will never be executed.
  257. - The Pagure before_request() retrieves the repository using the function
  258. pagure.lib.query.get_authorized_project() which checks for
  259. authorization and, if the repo is private, it returns 404. So there
  260. should be no need to check for authorization here.
  261. """
  262. project = pagure.lib.query.get_authorized_project(
  263. flask.g.session, repo, user=username, namespace=namespace)
  264. repository = flask.g.session.query(model.Repository) \
  265. .filter(model.Repository.id == project.id) \
  266. .one_or_none()
  267. if not repository:
  268. return ({}, 404)
  269. # This is a reference to the pygit2 repository object
  270. git_repository = flask.g.repo_obj
  271. return activitypub.Repository(project=project, repository=repository,
  272. git_repository=git_repository)
  273. @APP.route('/<repo>.git/refs')
  274. @APP.route('/<namespace>/<repo>.git/refs')
  275. @APP.route('/forks/<username>/<repo>.git/refs')
  276. @APP.route('/forks/<username>/<namespace>/<repo>.git/refs')
  277. def repository_refs(repo, username=None, namespace=None, *args, **kwargs):
  278. project = pagure.lib.query.get_authorized_project(
  279. flask.g.session, repo, user=username, namespace=namespace)
  280. repository = flask.g.session.query(model.Repository) \
  281. .filter(model.Repository.id == project.id) \
  282. .one_or_none()
  283. if not repository:
  284. return ({}, 404)
  285. # This is a reference to the pygit2 repository object
  286. git_repository = flask.g.repo_obj
  287. return activitypub.Refs(repository=repository,
  288. git_repository=git_repository)
  289. @APP.route('/<repo>.git/refs/tags/<tag>')
  290. @APP.route('/<namespace>/<repo>.git/refs/tags/<tag>')
  291. @APP.route('/forks/<username>/<repo>.git/refs/tags/<tag>')
  292. @APP.route('/forks/<username>/<namespace>/<repo>.git/refs/tags/<tag>')
  293. def repository_refs_tags(repo, tag, username=None, namespace=None, *args, **kwargs):
  294. project = pagure.lib.query.get_authorized_project(
  295. flask.g.session, repo, user=username, namespace=namespace)
  296. repository = flask.g.session.query(model.Repository) \
  297. .filter(model.Repository.id == project.id) \
  298. .one_or_none()
  299. if not repository:
  300. return ({}, 404)
  301. # This is a reference to the pygit2 repository object
  302. git_repository = flask.g.repo_obj
  303. tag_ref = 'refs/tags/{}'.format(tag)
  304. if tag_ref not in git_repository.references:
  305. return ({}, 404)
  306. return activitypub.TagRef(repository=repository, ref=tag_ref)
  307. @pagure_route('ui_ns.view_issues')
  308. def tickettracker(*args, **kwargs):
  309. username = kwargs.get('username')
  310. namespace = kwargs.get('namespace')
  311. repo = kwargs.get('repo')
  312. project = pagure.lib.query.get_authorized_project(
  313. flask.g.session, repo, user=username, namespace=namespace)
  314. if not project:
  315. return ({}, 404)
  316. tracker = flask.g.session.query(model.TicketTracker) \
  317. .filter(model.TicketTracker.id == project.id) \
  318. .one_or_none()
  319. if not tracker:
  320. return ({}, 404)
  321. return activitypub.TicketTracker(tracker=tracker, project=project)
  322. @pagure_route('ui_ns.view_issue')
  323. def ticket(*args, **kwargs):
  324. username = kwargs.get('username')
  325. namespace = kwargs.get('namespace')
  326. repo = kwargs.get('repo')
  327. issue_id = kwargs.get('issueid')
  328. repository = pagure.lib.query.get_authorized_project(
  329. flask.g.session, repo, user=username, namespace=namespace)
  330. if not repository:
  331. return ({}, 404)
  332. ticket = flask.g.session.query(model.Ticket) \
  333. .filter(model.Ticket.id == issue_id) \
  334. .filter(model.Ticket.project_id == repository.id) \
  335. .one_or_none()
  336. if not ticket:
  337. return ({}, 404)
  338. tickettracker = flask.g.session.query(model.TicketTracker) \
  339. .filter(model.TicketTracker.id == ticket.project.id) \
  340. .one_or_none()
  341. if not tickettracker:
  342. return ({}, 404)
  343. return activitypub.Ticket(ticket=ticket, tickettracker=tickettracker)
  344. @pagure_route('ui_ns.view_commit')
  345. def commit(*args, **kwargs):
  346. username = kwargs.get('username')
  347. namespace = kwargs.get('namespace')
  348. repo = kwargs.get('repo')
  349. commit_id = kwargs.get('commitid')
  350. repository = pagure.lib.query.get_authorized_project(
  351. flask.g.session, repo, user=username, namespace=namespace)
  352. if not repository:
  353. return ({}, 404)
  354. project = flask.g.session \
  355. .query(model.Project) \
  356. .filter(model.Project.id == repository.id) \
  357. .one_or_none()
  358. # This is a reference to the actual pygit2 repository. It's different
  359. # from the reference to the Pagure database object. This variable is
  360. # set by Pagure before_request()
  361. git_repository = flask.g.repo_obj
  362. branchname = flask.request.args.get('branch', None)
  363. if branchname and branchname not in git_repository.listall_branches():
  364. branchname = None
  365. try:
  366. commit = git_repository.get(commit_id)
  367. except ValueError:
  368. return ({}, 404)
  369. if commit is None:
  370. return ({}, 404)
  371. if isinstance(commit, pygit2.Blob):
  372. return ({}, 404)
  373. if isinstance(commit, pygit2.Tag):
  374. commit = commit.peel(pygit2.Commit)
  375. return flask.redirect(
  376. flask.url_for(
  377. 'ui_ns.view_commit',
  378. repo=repository.name,
  379. username=username,
  380. namespace=repository.namespace,
  381. commitid=commit.hex))
  382. return activitypub.Commit(project, repository, commit)
  383. @pagure_route('ui_ns.view_tree')
  384. def branch(*args, **kwargs):
  385. username = kwargs.get('username')
  386. namespace = kwargs.get('namespace')
  387. repo = kwargs.get('repo')
  388. branch_name = kwargs.get('identifier')
  389. repository = pagure.lib.query.get_authorized_project(
  390. flask.g.session, repo, user=username, namespace=namespace)
  391. if not repository:
  392. return ({}, 404)
  393. project = flask.g.session \
  394. .query(model.Project) \
  395. .filter(model.Project.id == repository.id) \
  396. .one_or_none()
  397. return activitypub.Branch(project, repository, branch_name)
  398. @pagure_route('ui_ns.request_pull')
  399. def merge_request(*args, **kwargs):
  400. username = kwargs.get('username')
  401. namespace = kwargs.get('namespace')
  402. repo = kwargs.get('repo')
  403. request_id = kwargs.get('requestid')
  404. repository = pagure.lib.query.get_authorized_project(
  405. flask.g.session, repo, user=username, namespace=namespace)
  406. if not repository:
  407. return ({}, 404)
  408. mergerequest = flask.g.session \
  409. .query(model.MergeRequest) \
  410. .filter(model.MergeRequest.id == request_id,
  411. model.MergeRequest.project_id == repository.id) \
  412. .one_or_none()
  413. if not mergerequest:
  414. return ({}, 404)
  415. return activitypub.MergeRequest(mergerequest)
  416. log.info('forgefed plugin registered by Flask.')
  417. # WebFinger
  418. #
  419. # This is primarily used to support other ActivityPub software such as
  420. # Mastodon that relies on webfinger as a discovery protocol because users
  421. # use @username@domain when mentioning other users.
  422. # https://docs.joinmastodon.org/spec/webfinger/
  423. # -----------------------------------------------------------------------------
  424. @APP.route('/.well-known/host-meta', methods=['GET'])
  425. def host_meta():
  426. return flask.render_template('host-meta.xml', APP_URL=APP_URL)
  427. @APP.route('/.well-known/webfinger/<path:uri>', methods=['GET'])
  428. def webfinger_resource(uri):
  429. """
  430. Return the webfinger info for account "uri".
  431. :param uri: The "acct:userpard@host" to search.
  432. """
  433. # Only support acct: resources
  434. # Do we need to support other schemes? WebFinger is neutral regarding the
  435. # scheme of URI: it could be "acct", "http", "https", "mailto", or some
  436. # other scheme, but other AcitivityPub instances such as Mastodon only
  437. # use "acct".
  438. if not uri.startswith('acct:'):
  439. return ({}, 404)
  440. # The "acct" scheme is defined in the spec as
  441. # "acct" ":" userpart "@" host
  442. # "host" is the domain where the account is hosted
  443. # "userpart" contains the localinfo used by the host to retrieve the account
  444. userpart, host = uri[5:].rsplit('@', 1)
  445. # Now we find the actual actor's URI.
  446. # The "userpart" is basically the "preferredUsername" property defined in
  447. # model.py
  448. if userpart.startswith('project/'):
  449. actor_uri = '{}/{}'.format(APP_URL, userpart[8:])
  450. else:
  451. actor_uri = '{}/user/{}'.format(APP_URL, userpart)
  452. return flask.render_template('webfinger.json', subject=uri, actor_uri=actor_uri)
  453. # NodeInfo
  454. #
  455. # This is used for supporting the NodeInfo discoverability protocol.
  456. # http://nodeinfo.diaspora.software/schema.html
  457. # There was a proposal for supporting ServiceInfo too. ServiceInfo is a fork of
  458. # NodeInfo, and should be an improved version of the former. However, the spec
  459. # has stalled and it's not been developed to completion.
  460. # -----------------------------------------------------------------------------
  461. @APP.route('/.well-known/nodeinfo', methods=['GET'])
  462. def nodeinfo():
  463. """
  464. Return the JRD document as dictated by the NodeInfo specification.
  465. """
  466. return {
  467. 'links': [
  468. {
  469. 'rel': 'http://nodeinfo.diaspora.software/ns/schema/2.1',
  470. 'href': '{}{}'.format(APP_URL, flask.url_for('forgefed_ns.nodeinfo2_1'))
  471. },
  472. {
  473. 'rel': 'http://nodeinfo.diaspora.software/ns/schema/2.0',
  474. 'href': '{}{}'.format(APP_URL, flask.url_for('forgefed_ns.nodeinfo2_0'))
  475. },
  476. {
  477. 'rel': 'http://nodeinfo.diaspora.software/ns/schema/1.1',
  478. 'href': '{}{}'.format(APP_URL, flask.url_for('forgefed_ns.nodeinfo1_1'))
  479. },
  480. {
  481. 'rel': 'http://nodeinfo.diaspora.software/ns/schema/1.0',
  482. 'href': '{}{}'.format(APP_URL, flask.url_for('forgefed_ns.nodeinfo1_0'))
  483. }
  484. ]
  485. }
  486. @APP.route('/nodeinfo/2.1', methods=['GET'])
  487. def nodeinfo2_1():
  488. """
  489. Return the NodeInfo document v2.1
  490. """
  491. # In order to get the real number of users of this instance we need to subtract
  492. # the accounts used for remote users.
  493. # Use func() to avoid sub-queries, as described here: https://docs.sqlalchemy.org/en/14/orm/query.html?highlight=count#sqlalchemy.orm.Query.count
  494. total_users = flask.g.session.query(sqlalchemy.func.count(model.Person.id)).scalar()
  495. return {
  496. 'version': '2.1',
  497. 'software': {
  498. 'name': 'Pagure',
  499. 'version': pagure.__version__,
  500. 'repository': 'https://pagure.io/pagure',
  501. 'homepage': 'https://pagure.io'
  502. },
  503. 'protocols': [ 'activitypub', 'forgefed' ],
  504. 'services': {
  505. 'inbound': [],
  506. 'outbound': []
  507. },
  508. 'openRegistrations': pagure.config.config.get('ALLOW_USER_REGISTRATION', False),
  509. 'usage': {
  510. 'users': {
  511. 'total': total_users,
  512. # 'activeHalfyear': 0,
  513. # 'activeMonth': 0
  514. },
  515. # 'localPosts': 0,
  516. # 'localComments': 0
  517. },
  518. 'metadata': {}
  519. }
  520. @APP.route('/nodeinfo/2.0', methods=['GET'])
  521. def nodeinfo2_0():
  522. """
  523. Return the NodeInfo document v2.0
  524. """
  525. # In order to get the real number of users of this instance we need to subtract
  526. # the accounts used for remote users.
  527. # Use func() to avoid sub-queries, as described here: https://docs.sqlalchemy.org/en/14/orm/query.html?highlight=count#sqlalchemy.orm.Query.count
  528. total_users = flask.g.session.query(sqlalchemy.func.count(model.Person.id)).scalar()
  529. return {
  530. 'version': '2.0',
  531. 'software': {
  532. 'name': 'Pagure',
  533. 'version': pagure.__version__
  534. },
  535. 'protocols': [ 'activitypub', 'forgefed' ],
  536. 'services': {
  537. 'inbound': [],
  538. 'outbound': []
  539. },
  540. 'openRegistrations': pagure.config.config.get('ALLOW_USER_REGISTRATION', False),
  541. 'usage': {
  542. 'users': {
  543. 'total': total_users,
  544. # 'activeHalfyear': 0,
  545. # 'activeMonth': 0
  546. },
  547. # 'localPosts': 0,
  548. # 'localComments': 0
  549. },
  550. 'metadata': {}
  551. }
  552. @APP.route('/nodeinfo/1.1', methods=['GET'])
  553. def nodeinfo1_1():
  554. """
  555. Return the NodeInfo document v1.1
  556. """
  557. # In order to get the real number of users of this instance we need to subtract
  558. # the accounts used for remote users.
  559. # Use func() to avoid sub-queries, as described here: https://docs.sqlalchemy.org/en/14/orm/query.html?highlight=count#sqlalchemy.orm.Query.count
  560. total_users = flask.g.session.query(sqlalchemy.func.count(model.Person.id)).scalar()
  561. return {
  562. 'version': '1.1',
  563. 'software': {
  564. 'name': 'Pagure',
  565. 'version': pagure.__version__
  566. },
  567. 'protocols': {
  568. 'inbound': [ 'activitypub', 'forgefed' ],
  569. 'outbound': [ 'activitypub', 'forgefed' ]
  570. },
  571. 'services': {
  572. 'inbound': [],
  573. 'outbound': []
  574. },
  575. 'openRegistrations': pagure.config.config.get('ALLOW_USER_REGISTRATION', False),
  576. 'usage': {
  577. 'users': {
  578. 'total': total_users,
  579. # 'activeHalfyear': 0,
  580. # 'activeMonth': 0
  581. },
  582. # 'localPosts': 0,
  583. # 'localComments': 0
  584. },
  585. 'metadata': {}
  586. }
  587. @APP.route('/nodeinfo/1.0', methods=['GET'])
  588. def nodeinfo1_0():
  589. """
  590. Return the NodeInfo document v1.0
  591. """
  592. # In order to get the real number of users of this instance we need to subtract
  593. # the accounts used for remote users.
  594. # Use func() to avoid sub-queries, as described here: https://docs.sqlalchemy.org/en/14/orm/query.html?highlight=count#sqlalchemy.orm.Query.count
  595. total_users = flask.g.session.query(sqlalchemy.func.count(model.Person.id)).scalar()
  596. return {
  597. 'version': '1.0',
  598. 'software': {
  599. 'name': 'Pagure',
  600. 'version': pagure.__version__
  601. },
  602. 'protocols': {
  603. 'inbound': [ 'activitypub', 'forgefed' ],
  604. 'outbound': [ 'activitypub', 'forgefed' ]
  605. },
  606. 'services': {
  607. 'inbound': [],
  608. 'outbound': []
  609. },
  610. 'openRegistrations': pagure.config.config.get('ALLOW_USER_REGISTRATION', False),
  611. 'usage': {
  612. 'users': {
  613. 'total': total_users,
  614. # 'activeHalfyear': 0,
  615. # 'activeMonth': 0
  616. },
  617. # 'localPosts': 0,
  618. # 'localComments': 0
  619. },
  620. 'metadata': {}
  621. }
  622. # Routes used to interact with remote objects of the federation, when we
  623. # cannot reuse another Pagure route.
  624. # -----------------------------------------------------------------------------
  625. @APP.route('/federation', methods=['GET'])
  626. @requires_login
  627. def federation():
  628. """
  629. Entry page for federation.
  630. """
  631. person = flask.g.session \
  632. .query(model.Person) \
  633. .filter(model.Person.id == flask.g.fas_user.id) \
  634. .one_or_none()
  635. if not person:
  636. return flask.redirect('/')
  637. # Retrieve feeds of the current user
  638. items = flask.g.session \
  639. .query(model.Feed) \
  640. .filter(model.Feed.actor_uri == person.uri) \
  641. .order_by(model.Feed.created.desc()) \
  642. .all()
  643. # A feed "content" property contains a serialized Python dictionary.
  644. # We convert strings back to dictionary before rendering the template.
  645. feed_items = [
  646. { 'created': feed.created, 'content': json.loads(feed.content) }
  647. for feed in items
  648. ]
  649. return flask.render_template('federation/feeds.html', feeds=feed_items)
  650. @APP.route('/federation/activity/<id>', methods=['GET'])
  651. def federation_activity(id):
  652. """
  653. Return the JSON representation of Activities created by this instance.
  654. """
  655. resource = flask.g.session \
  656. .query(model.Resource) \
  657. .filter(model.Resource.uri == '{}/federation/activity/{}'.format(APP_URL, id)) \
  658. .one_or_none()
  659. if not resource:
  660. return ({}, 404)
  661. activity = json.loads(resource.document)
  662. return activity
  663. @APP.route('/federation/followers', methods=['GET'])
  664. @requires_login
  665. def federation_followers():
  666. user = flask.g.session \
  667. .query(model.Person) \
  668. .filter(model.Person.id == flask.g.fas_user.id) \
  669. .one_or_none()
  670. if not user:
  671. return flask.redirect(flask.url_for('forgefed_ns.federation'))
  672. person = activitypub.fetch(user.uri)
  673. # Retrieve "following" for the current user
  674. items = flask.g.session \
  675. .query(model.Collection, model.Resource) \
  676. .join(model.Resource, model.Collection.item == model.Resource.uri) \
  677. .filter(model.Collection.uri == person['followers']) \
  678. .all()
  679. items = [ json.loads(resource.document) for collection, resource in items ]
  680. items = sorted(items, key=lambda i: i['name'].casefold())
  681. return flask.render_template('federation/followers.html', followers=items)
  682. @APP.route('/federation/following', methods=['GET'])
  683. @requires_login
  684. def federation_following():
  685. user = flask.g.session \
  686. .query(model.Person) \
  687. .filter(model.Person.id == flask.g.fas_user.id) \
  688. .one_or_none()
  689. if not user:
  690. return flask.redirect(flask.url_for('forgefed_ns.federation'))
  691. person = activitypub.fetch(user.uri)
  692. # Retrieve "following" for the current user
  693. items = flask.g.session \
  694. .query(model.Collection, model.Resource) \
  695. .join(model.Resource, model.Collection.item == model.Resource.uri) \
  696. .filter(model.Collection.uri == person['following']) \
  697. .all()
  698. items = [ json.loads(resource.document) for collection, resource in items ]
  699. items = sorted(items, key=lambda i: i['name'].casefold())
  700. return flask.render_template('federation/following.html', following=items)
  701. @APP.route('/federation/search', methods=['GET'])
  702. @requires_login
  703. def federation_search():
  704. uri = flask.request.args.get('uri')
  705. result = None
  706. # Search for an object
  707. if uri:
  708. result = activitypub.fetch(uri)
  709. return flask.render_template(
  710. 'federation/search.html',
  711. uri=uri,
  712. result=result)
  713. @APP.route('/federation/follow', methods=['GET'])
  714. @requires_login
  715. def federation_follow():
  716. remote_actor_uri = flask.request.args.get('actor_uri')
  717. # The user that clicked the "Follow" button
  718. person = flask.g.session \
  719. .query(model.Person) \
  720. .filter(model.Person.id == flask.g.fas_user.id) \
  721. .one_or_none()
  722. # The user that clicked "Follow" doesn't exist?!
  723. if not person:
  724. return flask.redirect(flask.url_for('forgefed_ns.federation'))
  725. person_jsonld = activitypub.fetch(person.local_uri)
  726. remote_actor = activitypub.fetch(remote_actor_uri)
  727. if not remote_actor:
  728. raise Exception('Could not fetch remote actor.')
  729. flask.g.session.add(model.Feed(person.uri, feeds.follow(person_jsonld, remote_actor)))
  730. # Send a notification because Pagure doesn't have any notification for
  731. # this event.
  732. pagure.lib.notify.log(None, 'forgefed.follow', {
  733. 'follower': person_jsonld,
  734. 'followed': remote_actor})
  735. flask.g.session.commit()
  736. return flask.redirect(flask.url_for('forgefed_ns.federation'))
  737. @APP.route('/federation/submit_ticket', methods=['GET'])
  738. @requires_login
  739. def federation_submit_ticket():
  740. # The URL of the remote tracker (actor)
  741. tickettracker_uri = flask.request.args.get('actor_uri')
  742. if not tickettracker_uri:
  743. return flask.redirect(flask.url_for('ui_ns.index'))
  744. # Retrieve the remote actor
  745. tickettracker_jsonld = activitypub.fetch(tickettracker_uri)
  746. if not tickettracker_jsonld:
  747. log.debug('Cannot submit Ticket because cannot fetch remote TicketTracker.')
  748. flask.abort()
  749. # Check if the TicketTracker already exists
  750. tickettracker = model.from_uri(flask.g.session, tickettracker_uri)
  751. # TicketTracker already exists:
  752. if tickettracker:
  753. if not isinstance(tickettracker, model.TicketTracker):
  754. log.debug('{} is not a valid TicketTracker.'.format(tickettracker_uri))
  755. flask.abort()
  756. return flask.redirect(flask.url_for('ui_ns.new_issue',
  757. repo=tickettracker.name,
  758. username=tickettracker.user.username if tickettracker.is_fork else None,
  759. namespace=tickettracker.namespace))
  760. # TicketTracker doesn't exist
  761. log.debug('Creating TicketTracket for remote object {}'.format(tickettracker_uri))
  762. # The user that clicked the "Open ticket" button
  763. person = flask.g.session \
  764. .query(model.Person) \
  765. .filter(model.Person.id == flask.g.fas_user.id) \
  766. .one_or_none()
  767. if not person:
  768. return flask.redirect(flask.url_for('forgefed_ns.federation'))
  769. # Create a local user for the project because in Pagure a project must
  770. # have a user ID
  771. forgefed_user = test_or_set_forgefed_user(flask.g.session)
  772. project_name = 'forgefed:tracker:{}@{}'.format(
  773. model.safe_uri(tickettracker_jsonld['name']),
  774. model.safe_uri(urllib.parse.urlparse(tickettracker_uri).netloc))
  775. # Create the project in the Pagure database.
  776. # This function will create the item in the database, and then will
  777. # start a new async task to create the actual .git folder.
  778. task = pagure.lib.query.new_project(
  779. session=flask.g.session,
  780. user=forgefed_user.username,
  781. name=project_name,
  782. blacklist=[],
  783. allowed_prefix=[],
  784. repospanner_region=None,
  785. description='Remote tracker.',
  786. url=tickettracker_uri,
  787. avatar_email=None,
  788. parent_id=None,
  789. add_readme=False,
  790. namespace=None,
  791. user_ns=False,
  792. ignore_existing_repo=False,
  793. private=False,
  794. )
  795. # We need to wait for the project to be created, then we redirect to the
  796. # project's issues page
  797. task_result = task.wait(timeout=None, interval=0.5)
  798. tickettracker = flask.g.session \
  799. .query(model.TicketTracker) \
  800. .filter(model.TicketTracker.name == project_name) \
  801. .one_or_none()
  802. # Disable pull requests for this project, since it's only used as a
  803. # ticket tracker
  804. tickettracker.settings = {
  805. **tickettracker.settings,
  806. 'issue_tracker': True,
  807. 'project_documentation': False,
  808. 'pull_requests': False,
  809. 'pull_request_access_only': True,
  810. 'issue_tracker_read_only': False,
  811. }
  812. # The tracker that we've just created is only used to interact with a
  813. # remote one. It's not a "real" local tracker by a local user, so we
  814. # create a SameAs relation in the graph.
  815. #tracker_url = '{}/{}/issues'.format(APP_URL, user.username)
  816. tickettracker.remote_uri = tickettracker_uri
  817. # Make sure that this User is following the tracker, so that per will receive
  818. # notifications
  819. activity = activitypub.Activity(
  820. type = 'Follow',
  821. actor = person.uri,
  822. object = tickettracker.remote_uri,
  823. to = tickettracker.remote_uri)
  824. activity.distribute()
  825. flask.g.session.commit()
  826. # Redirect to the "new issue" page of the local tracker, where the user can
  827. # create a new issue for the remote tracker.
  828. return flask.redirect(flask.url_for('ui_ns.new_issue', repo=project_name))
  829. @APP.route('/federation/ticket/<issue_uid>/comments', methods=['GET'])
  830. def federation_ticket_comments(issue_uid):
  831. """
  832. Return the Collection containing a ticket's comments.
  833. This route exists because pagure does not have any route defined for comments.
  834. The path of a comment in pagure is /<repo>/issue/<issueid>#comment-<commend_id>
  835. but the fragment of the URL (after the # symbol) is not sent to the server.
  836. :param issue_uid: The unique ID defined by pagure during ticket creation. This
  837. is used instead of the default key which is (project_id, issue_id).
  838. """
  839. collection_uri = APP_URL + flask.request.path
  840. return activitypub.OrderedCollection(collection_uri)
  841. @APP.route('/federation/ticket/<issue_uid>/comments/<int:page>', methods=['GET'])
  842. def federation_ticket_comments_page(issue_uid):
  843. """
  844. Return the CollectionPage containing a ticket's comments.
  845. :param issue_uid: The unique ID defined by pagure during ticket creation. This
  846. is used instead of the default key which is (project_id, issue_id).
  847. :param page: The page of the collection to show.
  848. """
  849. page_uri = APP_URL + flask.request.path
  850. collection_uri = page_uri.rsplit('/', 1)[0]
  851. # Retrieve items
  852. items = flask.g.session \
  853. .query(model.TicketComment) \
  854. .filter(model.TicketComment.issue_uid == issue_uid) \
  855. .offset(settings.COLLECTION_SIZE * page) \
  856. .limit(settings.COLLECTION_SIZE) \
  857. .all()
  858. items_ids = [ result.local_uri for result in items ]
  859. return {
  860. '@context': activitypub.jsonld_context,
  861. 'type': 'OrderedCollectionPage',
  862. 'id': page_uri,
  863. 'partOf': collection_uri,
  864. 'orderedItems': items_ids }
  865. @APP.route('/federation/ticket_comment/<comment_id>', methods=['GET'])
  866. def federation_ticket_comment(comment_id):
  867. """
  868. Return the JSONLD of a ticket comment. This is required because Pagure
  869. doesn't have any route dedicated to show a single comment.
  870. :param comment_id: The comment ID defined by pagure during comment creation.
  871. This is the default primary key of the comment and is unique across all
  872. local issues.
  873. """
  874. comment = flask.g.session \
  875. .query(model.TicketComment) \
  876. .filter(model.TicketComment.id == comment_id) \
  877. .one_or_none()
  878. return activitypub.TicketComment(comment=comment)
  879. @APP.route('/federation/fork', methods=['GET'])
  880. @requires_login
  881. def federation_fork():
  882. # The URL of the remote tracker (actor)
  883. repository_uri = flask.request.args.get('repository_uri')
  884. if not repository_uri:
  885. return flask.redirect(flask.url_for('ui_ns.index'))
  886. # The user that clicked the "Fork" button
  887. person = flask.g.session \
  888. .query(model.Person) \
  889. .filter(model.Person.id == flask.g.fas_user.id) \
  890. .one_or_none()
  891. if not person:
  892. return flask.redirect(flask.url_for('forgefed_ns.federation'))
  893. # Retrieve the remote actor
  894. repository_jsonld = activitypub.fetch(repository_uri)
  895. if not repository_jsonld:
  896. log.debug('Cannot fork because cannot fetch remote Repository.')
  897. flask.abort()
  898. # Check if the Repository already exists
  899. repository = model.from_uri(flask.g.session, repository_uri)
  900. # Repository already exists?
  901. if repository:
  902. if not isinstance(repository, model.Repository):
  903. log.debug('{} is not a valid Repository.'.format(repository_uri))
  904. flask.abort()
  905. else:
  906. log.debug('Creating Repository for remote object {}'.format(repository_uri))
  907. # Create a local user for the project because in Pagure a project must
  908. # have a user ID
  909. forgefed_user = test_or_set_forgefed_user(flask.g.session)
  910. project_name = 'forgefed:repository:{}@{}'.format(
  911. model.safe_uri(repository_jsonld['name']),
  912. model.safe_uri(urllib.parse.urlparse(repository_uri).netloc))
  913. # Create the project in the Pagure database.
  914. # This function will create the item in the database, and then will
  915. # start a new async task to create the actual .git folder.
  916. task = pagure.lib.query.new_project(
  917. session=flask.g.session,
  918. user=forgefed_user.username,
  919. name=project_name,
  920. blacklist=[],
  921. allowed_prefix=[],
  922. repospanner_region=None,
  923. description='Remote repository.',
  924. url=repository_uri,
  925. avatar_email=None,
  926. parent_id=None,
  927. add_readme=False,
  928. mirrored_from=repository_uri,
  929. namespace=None,
  930. user_ns=False,
  931. ignore_existing_repo=False,
  932. private=False,
  933. )
  934. # We need to wait for the project to be created, then we redirect to the
  935. # project's issues page
  936. task_result = task.wait(timeout=None, interval=0.5)
  937. repository = flask.g.session \
  938. .query(model.Repository) \
  939. .filter(model.Repository.name == project_name,
  940. model.Repository.is_fork == False) \
  941. .one_or_none()
  942. if not repository:
  943. log.critical('Project repository was not created before forking.')
  944. flask.abort(500)
  945. # Disable tickets for this project, since it's only used as a mirror
  946. repository.settings = {
  947. **repository.settings,
  948. 'issue_tracker': False,
  949. 'project_documentation': False,
  950. 'pull_requests': True,
  951. 'pull_request_access_only': True,
  952. 'issue_tracker_read_only': True,
  953. }
  954. # The repository that we've just created is only used to interact with a
  955. # remote one. It's not a "real" local repository by a local user, so we
  956. # create a SameAs relation in the graph.
  957. repository.remote_uri = repository_uri
  958. # Redirect to the user fork if the user already forked this repository
  959. project_fork = pagure.lib.query._get_project(
  960. session=flask.g.session,
  961. name=repository.name,
  962. user=person.username,
  963. namespace=repository.namespace)
  964. if project_fork:
  965. return flask.redirect(
  966. flask.url_for(
  967. 'ui_ns.view_repo',
  968. repo=project_fork.name,
  969. username=person.username,
  970. namespace=project_fork.namespace))
  971. log.debug('Forking {} for user {}'.format(repository.uri, person.uri))
  972. # TODO This mirror is happening synchronously. How can I use Pagure "wait
  973. # screen"?
  974. if repository.is_remote:
  975. pagure.lib.git.mirror_pull_project(flask.g.session, repository)
  976. task = pagure.lib.query.fork_project(
  977. session=flask.g.session, repo=repository, user=person.username)
  978. task_result = task.wait(timeout=None, interval=0.5)
  979. # Make sure that this User is following the tracker, so that per will receive
  980. # notifications
  981. activitypub.Activity(
  982. type = 'Follow',
  983. actor = person.uri,
  984. object = repository.remote_uri,
  985. to = repository.remote_uri
  986. ).distribute()
  987. flask.g.session.commit()
  988. # Redirect to the user fork
  989. return flask.redirect(
  990. flask.url_for(
  991. 'ui_ns.view_repo',
  992. repo=repository.name,
  993. username=person.username,
  994. namespace=repository.namespace))
  995. @APP.route('/federation/mergerequest/<mergerequest_uid>/comments', methods=['GET'])
  996. def federation_mergerequest_comments(mergerequest_uid):
  997. """
  998. Return the Collection containing a MergeRequest's comments.
  999. This route exists because pagure does not have any route defined for comments.
  1000. :param mergerequest_uid: The unique ID defined by Pagure when it creates a
  1001. PullRequest. This is used instead of the default primary_key which is the
  1002. tuple (id, project_id).
  1003. """
  1004. collection_uri = APP_URL + flask.request.path
  1005. return activitypub.OrderedCollection(collection_uri)
  1006. @APP.route('/federation/mergerequest/<mergerequest_uid>/comments/<int:page>', methods=['GET'])
  1007. def federation_mergerequest_comments_page(mergerequest_uid):
  1008. """
  1009. Return the CollectionPage containing a MergeRequest's comments.
  1010. :param mergerequest_uid: The unique ID defined by Pagure when it creates a
  1011. PullRequest. This is used instead of the default primary_key which is the
  1012. tuple (id, project_id).
  1013. :param page: The page of the collection to show.
  1014. """
  1015. page_uri = APP_URL + flask.request.path
  1016. collection_uri = page_uri.rsplit('/', 1)[0]
  1017. # Retrieve items
  1018. items = flask.g.session \
  1019. .query(model.MergeRequestComment) \
  1020. .filter(model.MergeRequestComment.pull_request_uid == mergerequest_uid) \
  1021. .offset(settings.COLLECTION_SIZE * page) \
  1022. .limit(settings.COLLECTION_SIZE) \
  1023. .all()
  1024. items_ids = [ result.local_uri for result in items ]
  1025. return {
  1026. '@context': activitypub.jsonld_context,
  1027. 'type': 'OrderedCollectionPage',
  1028. 'id': page_uri,
  1029. 'partOf': collection_uri,
  1030. 'orderedItems': items_ids }
  1031. @APP.route('/federation/mergerequest_comment/<comment_id>', methods=['GET'])
  1032. def federation_mergerequest_comment(comment_id):
  1033. """
  1034. Return the JSONLD of a MergeRequest comment. This is required because Pagure
  1035. doesn't have any route dedicated to show a single comment.
  1036. :param comment_id: The comment ID defined by Pagure during comment creation.
  1037. This is the default primary key of the comment and is unique across all
  1038. local pull-requests.
  1039. """
  1040. comment = flask.g.session \
  1041. .query(model.MergeRequestComment) \
  1042. .filter(model.MergeRequestComment.id == comment_id) \
  1043. .one_or_none()
  1044. return activitypub.MergeRequestComment(comment=comment)
  1045. # Repository
  1046. # ----------
  1047. @APP.route('/federation/tag/<id>', methods=['GET'])
  1048. def project_tag(id):
  1049. """
  1050. Display a Tag object.
  1051. """
  1052. tag = flask.g.session \
  1053. .query(model.Tag) \
  1054. .filter(model.Tag.id == id) \
  1055. .one_or_none()
  1056. if not tag:
  1057. return ({}, 404)
  1058. return activitypub.Tag(tag=tag)
  1059. # Actor
  1060. # -----
  1061. @APP.route('/<path:actor>/key.pub', methods=['GET'])
  1062. def actor_key(actor):
  1063. """
  1064. This object represents the public GPG key used to sign HTTP requests.
  1065. """
  1066. actor_path = '/' + actor
  1067. actor_uri = APP_URL + '/' + actor
  1068. actor = model.from_path(flask.g.session, actor_path)
  1069. key_uri = APP_URL + flask.request.path
  1070. if not actor:
  1071. return ({}, 404)
  1072. # Create key if it doesn't exist
  1073. model.GpgKey.test_or_set(flask.g.session, actor_uri, key_uri)
  1074. # Get the key
  1075. key = flask.g.session.query(model.GpgKey) \
  1076. .filter(model.GpgKey.uri == key_uri) \
  1077. .one_or_none()
  1078. return activitypub.CryptographicKey(key=key)
  1079. @APP.route('/<path:actor>/sshkey/<path:key>', methods=['GET'])
  1080. def actor_ssh_key(actor, key):
  1081. """
  1082. Return an Actor's SSH key.
  1083. :param key: An SSH key identifier as created by Pagure when users insert their
  1084. public keys in the user settings. This corresponds to the SSHKey.ssh_search_key
  1085. property in the Pagure model.
  1086. """
  1087. actor_path = '/' + actor
  1088. actor_uri = APP_URL + '/' + actor
  1089. actor = model.from_path(flask.g.session, actor_path)
  1090. key_uri = APP_URL + flask.request.path
  1091. if not actor:
  1092. return ({}, 404)
  1093. # Get the key
  1094. key = flask.g.session \
  1095. .query(model.SshKey) \
  1096. .filter(model.SshKey.ssh_search_key == key) \
  1097. .one_or_none()
  1098. if not key:
  1099. return ({}, 404)
  1100. return activitypub.SshKey(key=key)
  1101. @APP.route('/<path:project>/role/<role>', methods=['GET'])
  1102. def project_roles(project, role):
  1103. """
  1104. Returns a Role.
  1105. """
  1106. role = flask.g.session \
  1107. .query(pagure.lib.model.AccessLevels) \
  1108. .filter(pagure.lib.model.AccessLevels.access == role) \
  1109. .one_or_none()
  1110. if not role:
  1111. log.debug('The Role {} does not exist.'.format(role))
  1112. return ({}, 404)
  1113. project_path = '/' + project
  1114. project_uri = APP_URL + project_path
  1115. project = model.from_uri(flask.g.session, project_uri)
  1116. if not project:
  1117. log.debug('The Project {} does not exist.'.format(project_uri))
  1118. return ({}, 404)
  1119. return activitypub.Role(project=project, role=role)
  1120. @APP.route('/<path:actor>/inbox', methods=['GET'])
  1121. def actor_inbox(actor):
  1122. """
  1123. Returns an Actor's INBOX.
  1124. This should only be called by the Actors who want to read their own INBOX.
  1125. """
  1126. return ({}, 501) # 501 Not Implemented. TODO implement C2S.
  1127. @APP.route('/<path:actor>/outbox', methods=['GET'])
  1128. def actor_outbox(actor, page=None):
  1129. """
  1130. Returns an Actor's OUTBOX.
  1131. This should only be called by the Actors who want to read their own OUTBOX.
  1132. """
  1133. # TODO Show only "Public" OUTBOX
  1134. # https://www.w3.org/TR/activitypub/#public-addressing
  1135. return ({}, 501) # 501 Not Implemented. TODO implement C2S.
  1136. @APP.route('/<path:actor>/inbox', methods=['POST'])
  1137. def actor_receive(actor):
  1138. """
  1139. Somebody is sending a message to an actor's INBOX.
  1140. """
  1141. actor_path = '/' + actor
  1142. actor_uri = APP_URL + '/' + actor
  1143. # Retrieve the ActivityPub Activity from the HTTP request body. The
  1144. # Activity is expected to be a JSON object.
  1145. # https://flask.palletsprojects.com/en/1.1.x/api/#flask.Request.get_json
  1146. activity = flask.request.get_json()
  1147. activity = activitypub.Activity.from_dict(activity)
  1148. # Verify the HTTP signature of the incoming request. The HTTP POST request
  1149. # must have been signed with the Actor public key.
  1150. class SignatureMismatch(Exception):
  1151. def __init__(self, key_id, actor_id):
  1152. """
  1153. :param key_id: The key ID in the HTTP signature
  1154. :param actor_id: The ID of the Activity Actor.
  1155. """
  1156. self.key_id = key_id
  1157. self.actor_id = actor_id
  1158. def __str__(self):
  1159. return 'HTTP request signed by a different Actor:\n' + \
  1160. ' key_id: {}\n'.format(self.key_id) + \
  1161. ' Actor: {}\n'.format(self.actor_id)
  1162. def key_resolver(key_id, algorithm):
  1163. # Fetch the remote Actor's key
  1164. remote_actor_key = activity.node('actor').node('publicKey')
  1165. if key_id != activity['actor']['id']:
  1166. raise SignatureMismatch(key_id, activity['actor']['id'])
  1167. # .encode() will encode the UTF-8 string to bytes. We need this because
  1168. # the 'publicKeyPem' retrieved from the ActivityPub JSON-LD is in UTF-8
  1169. # format but the requests_http_signature module requires raw bytes data.
  1170. return remote_actor_key['publicKeyPem'].encode()
  1171. signature_verified = False
  1172. try:
  1173. requests_http_signature.HTTPSignatureAuth.verify(
  1174. flask.request, key_resolver=key_resolver, scheme='Signature')
  1175. except SignatureMismatch:
  1176. """
  1177. If the HTTP Signature key_id does not match the Actor ID, it means that
  1178. the Activity was POSTed by a different Actor than the Activity actual Actor.
  1179. This probably means that the Activity was forwarded (unless it was forged!).
  1180. """
  1181. pass
  1182. except Exception as e:
  1183. log.error(e)
  1184. log.error('Could not verify HTTP signature for incoming Activity'.format(activity['id']))
  1185. return ({}, 400) # Bad Request
  1186. else:
  1187. log.debug('HTTP signature for incoming Activity {} verified.'.format(activity['id']))
  1188. signature_verified = True
  1189. # Schedule a task to process the incoming activity asynchronously
  1190. tasks.delivery.validate_incoming_activity.delay(actor_uri, activity, signature_verified)
  1191. return ({}, 202) # 202 Accepted
  1192. @APP.route('/<path:actor>/outbox', methods=['POST'])
  1193. def actor_send(actor):
  1194. """
  1195. An Actor is trying to POST an Activity to its OUTBOX.
  1196. This should only be called by an Actor's client that want to send out a new
  1197. Activity.
  1198. """
  1199. # TODO
  1200. log.debug('Implement bearer token authentication for 3rd party clients')
  1201. return ({}, 501) # 501 Not Implemented
  1202. actor_uri = APP_URL + '/' + actor
  1203. # Retrieve the ActivityPub Activity from the HTTP request body. The
  1204. # Activity is expected to be a JSON object.
  1205. # https://flask.palletsprojects.com/en/1.1.x/api/#flask.Request.get_json
  1206. activity = flask.request.get_json()
  1207. # Schedule a task to process the incoming activity asynchronously
  1208. tasks.delivery.validate_outgoing_activity.delay(actor_uri, activity)
  1209. @APP.route('/<path:actor>/followers', methods=['GET'])
  1210. def actor_followers(actor):
  1211. """
  1212. Show the followers of an actor.
  1213. """
  1214. actor_path = '/' + actor
  1215. actor = model.from_path(flask.g.session, actor_path)
  1216. if not actor:
  1217. return ({}, 404)
  1218. collection_uri = APP_URL + flask.request.path
  1219. return activitypub.OrderedCollection(collection_uri=collection_uri)
  1220. @APP.route('/<path:actor>/followers/<int:page>', methods=['GET'])
  1221. def actor_followers_page(actor, page):
  1222. """
  1223. Show the followers of an actor.
  1224. """
  1225. page_uri = APP_URL + flask.request.path
  1226. collection_uri = page_uri.rsplit('/', 1)[0]
  1227. # Retrieve items
  1228. items = flask.g.session \
  1229. .query(model.Collection) \
  1230. .filter(model.Collection.uri == collection_uri) \
  1231. .order_by(model.Collection.added.desc()) \
  1232. .offset(settings.COLLECTION_SIZE * page) \
  1233. .limit(settings.COLLECTION_SIZE) \
  1234. .all()
  1235. items_uri = [ result.item for result in items ]
  1236. return activitypub.OrderedCollectionPage(collection_uri=collection_uri,
  1237. page_uri=page_uri,
  1238. items=items_uri)
  1239. @APP.route('/<path:actor>/following', methods=['GET'])
  1240. def actor_following(actor):
  1241. """
  1242. Show the actors that an actor is following.
  1243. """
  1244. actor_path = '/' + actor
  1245. actor = model.from_path(flask.g.session, actor_path)
  1246. if not actor:
  1247. return ({}, 404)
  1248. collection_uri = APP_URL + flask.request.path
  1249. return activitypub.OrderedCollection(collection_uri=collection_uri)
  1250. @APP.route('/<path:actor>/following/<int:page>', methods=['GET'])
  1251. def actor_following_page(actor, page):
  1252. """
  1253. Show the actors that an actor is following.
  1254. """
  1255. page_uri = APP_URL + flask.request.path
  1256. collection_uri = page_uri.rsplit('/', 1)[0]
  1257. # Retrieve items
  1258. items = flask.g.session \
  1259. .query(model.Collection) \
  1260. .filter(model.Collection.uri == collection_uri) \
  1261. .order_by(model.Collection.added.desc()) \
  1262. .offset(settings.COLLECTION_SIZE * page) \
  1263. .limit(settings.COLLECTION_SIZE) \
  1264. .all()
  1265. items_uri = [ result.item for result in items ]
  1266. return activitypub.OrderedCollectionPage(collection_uri=collection_uri,
  1267. page_uri=page_uri,
  1268. items=json.dumps(items_uri))
  1269. log.debug('forgefed plugin initialized.')