123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614 |
- """
- ForgeFed plugin for Pagure.
- Copyright (C) 2020-2021 zPlus <zplus@peers.community>
- This program is free software; you can redistribute it and/or modify
- it under the terms of the GNU General Public License as published by
- the Free Software Foundation; either version 2 of the License, or
- (at your option) any later version.
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU General Public License for more details.
- You should have received a copy of the GNU General Public License along
- with this program; if not, see <https://www.gnu.org/licenses/>.
- SPDX-FileCopyrightText: 2020-2021 zPlus <zplus@peers.community>
- SPDX-License-Identifier: GPL-2.0-only
- """
- import celery
- import flask
- import functools
- import json
- import os
- import logging
- import pagure
- import pagure.config
- import pagure.lib.git
- import pygit2
- import rdflib
- import requests
- import requests_http_signature
- import sqlalchemy
- import traceback
- import urllib
- from . import APP_URL
- from . import activitypub
- from . import feeds
- from . import model
- from . import settings
- from . import tasks
- log = logging.getLogger(__name__)
- log.debug('Initializing forgefed plugin...')
- # This Flask Blueprint will be imported by Pagure
- APP = flask.Blueprint('forgefed_ns', __name__, url_prefix='/',
- template_folder='templates')
- # TODO load Blueprint configuration from file if needed
- APP.config = {}
- def requires_login(func):
- """
- A decorator for routes to check user login.
- """
- @functools.wraps(func)
- def decorator(*args, **kwargs):
- if not flask.g.authenticated:
- return flask.redirect('/')
- # return ("", 401) # Unauthorized
- return func(*args, **kwargs)
- return decorator
- @APP.after_request
- def add_header(response):
- """
- Automatically set Content-Type header to all the Blueprint responses.
- # TODO Untangle this! Make it nicer.
- """
- # Return default headers
- if flask.request.path.startswith('/federation'):
- return response
- content_type = None
- if flask.request.path.startswith('/.well-known/host-meta'):
- content_type = 'application/xrd+xml; charset=utf-8'
- elif flask.request.path.startswith('/.well-known/webfinger'):
- content_type = 'application/jrd+json; charset=utf-8'
- elif flask.request.path.startswith('/.well-known/nodeinfo'):
- content_type = 'application/jrd+json; charset=utf-8'
- elif flask.request.path == '/nodeinfo/2.1':
- content_type = 'application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.1#"'
- else:
- content_type = activitypub.default_header
- response.headers['Content-Type'] = content_type
- return response
- @APP.record
- def override_pagure_routes(setup_state):
- """
- We reuse Pagure routes in order to return ActivityPub objects in response
- to "Accept: application/ld+json" request headers. The "record" decorator
- registers a callback function that is called during initialization of the
- Blueprint by Flask. While Flask offers the app context during requests
- handling, the same context is not available during initialization.
- Therefore we need this callback which is called during Flask initialization
- in order to get the app context that we need to replace the Pagure views.
- See https://flask.palletsprojects.com/en/1.1.x/api/#flask.Blueprint.record
- for more info about the record() function/decorator.
- NOTE - If Flask accepted route dispatching based on headers value, we could
- just use something like @APP.route(accept='application/activity+json').
- But it does not, so we need to override the Pagure views.
- - ActivityPub requires some routes to exist for Actors, for example
- "inbox" and "followers". However, Pagure does not have suitable
- routes that we can reuse for this purpose. For this reason, those
- routes are defined directly on the Blueprint.
- """
- # Reference to the main Flask app created by Pagure and to which Blueprints
- # will be attached.
- pagure_app = setup_state.app
- # We add our templates folder to the app's jinja path, such that we can
- # override pagure templates.
- pagure_app.jinja_loader \
- .searchpath \
- .insert(0, os.path.join(os.path.abspath(os.path.dirname(__file__)), 'templates'))
- # Create a symlink to pagure templates folder. This is only used when
- # "extending" templates using {% extends "master.html" %}, because extending
- # a template with the same name will trigger an infinite recursion.
- pagure_path_symlink = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'templates/__pagure__')
- if not os.path.islink(pagure_path_symlink):
- os.symlink(os.path.join(settings.PAGURE_PATH, 'pagure/templates/'), pagure_path_symlink)
- """
- DEPRECATED using Pagure own database instead
- @pagure_app.before_request
- def start_database_session():
- # At the beginning of every request we get a new connection to the
- # forgefed graph store. Please note that this function is executed
- # before *every* request, for every route, including the ones
- # defined in the Blueprint.
- flask.g.forgefed = database.start_database_session()
- @pagure_app.after_request
- def do_something(response):
- return response
- @pagure_app.teardown_request
- def free_database_session(exception=None):
- # Close and remove the database session that was initiated in
- # @before_requrest. This instruction should be optional since the object
- # should be automatically garbage-collected when the request is destroyed.
- flask.g.forgefed.commit()
- flask.g.forgefed.remove()
- """
- def pagure_route(endpoint):
- """
- This function returns a decorator whose job is to replace a Pagure
- view with another one that will check the HTTP "Accept" header. If the
- HTTP request is a ActivityPub one the ForgeFed plugin will take care of it,
- otherwise it will just pass through the control to the Pagure view.
- Additional documentation useful for this decorator: https://flask.palletsprojects.com/en/1.1.x/api/#flask.Flask.view_functions
- """
- def decorator(forgefed_view):
- # The Flask object "pagure_app.view_functions" contains all the
- # views defined by Pagure.
- pagure_view = pagure_app.view_functions[endpoint]
- # https://docs.python.org/3.9/library/functools.html#functools.wraps
- @functools.wraps(forgefed_view)
- def wrapper(*args, **kwargs):
- """
- DEPRECATED This is the old code that was used to parse the "Accept"
- header manually. It's since been replaced with
- flask.request.accept_mimetypes.best. This comment is only
- kept here temporarily just in case there are problems with the
- new code.
- # HTTP headers can contain multiple values separated by a comma
- accept_mimetypes = [
- value.strip(' ')
- for value
- in flask.request.accept_mimetypes.best ]
- if any(header in activitypub.headers for header in accept_mimetypes):
- response = flask.make_response(forgefed_view(*args, **kwargs))
- response.headers['Content-Type'] = activitypub.default_header
- return response
- """
- if flask.request.accept_mimetypes.best in activitypub.headers:
- response = flask.make_response(forgefed_view(*args, **kwargs))
- response.headers['Content-Type'] = activitypub.default_header
- return response
- # If it's not an ActivityPub request, just fall through to the
- # Pagure default view.
- return pagure_view(*args, **kwargs)
- # Replace the pagure view with our own
- pagure_app.view_functions[endpoint] = wrapper
- return wrapper
- return decorator
- ###########################################################################
- # ActivityPub Objects
- ###########################################################################
- @pagure_route('ui_ns.view_user')
- def person(*args, **kwargs):
- """
- Return a Person object from the Pagure user page.
- """
- # Retrieve path arguments
- username = kwargs.get('username')
- actor = flask.g.session \
- .query(pagure.lib.model.User) \
- .filter(pagure.lib.model.User.user == username) \
- .one_or_none()
- if not actor:
- return ({}, 404)
- return activitypub.Person(actor=actor)
- @pagure_route('ui_ns.view_group')
- def group(*args, **kwargs):
- """
- Return a Pagure Group object.
- """
- # Retrieve path arguments
- group = kwargs.get('group')
- group = pagure.lib.query.search_groups(
- flask.g.session, group_name=group, group_type="user")
- if not group:
- return ({}, 404)
- return activitypub.Group(group=group)
- @pagure_route('ui_ns.view_repo')
- def project(*args, **kwargs):
- """
- Return a Project object from the project page.
- """
- # Retrieve path arguments
- username = kwargs.get('username')
- repo = kwargs.get('repo')
- namespace = kwargs.get('namespace')
- repository = pagure.lib.query.get_authorized_project(
- flask.g.session, repo, user=username, namespace=namespace)
- project = flask.g.session.query(model.Project) \
- .filter(model.Project.id == repository.id) \
- .one_or_none()
- if not project:
- return ({}, 404)
- return activitypub.Project(project=project)
- @pagure_route('ui_ns.view_repo_git')
- @APP.route('/forks/<username>/<repo>.git')
- @APP.route('/forks/<username>/<namespace>/<repo>.git')
- def repository(repo, username=None, namespace=None, *args, **kwargs):
- """
- Return a Repository actor object.
- - Pagure has a route called "ui_ns.view_repo_git" that doesn't really do
- anything except redirect to the project URL (without ".git"). This URL
- is also used for GIT clone/push via HTTP. When a person types
- "git clone https://..." the GIT protocols appends some query arguments an
- these requests are proxied to the GIT backend. The backend uses the request
- path to match a repository on the filesystem. For example
- https://example.org/project1.git will match the folder $GIT_PROJECT_ROOT/project1.git.
- This works for projects (which in Pagure terminology are all the non-fork
- repositories) but it's broken for forks. The reason why it's broken is
- because all the forks are under the route "/fork/<username>/<projectname>.git"
- but the actual folder in the filesystem is "/forks/<username>/<projectname>.git".
- "/forks/<username>/<repo>" doesn't match any route in Pagure, but we need it
- for ForgeFed because we want the actual clonable URL to be also the Actor URL.
- Unfortunately this is not a priority for Pagure devs, and they're not gonna
- fix this "fork/forks" issue anytime soon. This is the reason why we need the
- extra routes, to match the actual clonable URL of the repository.
- NOTE This function's signature and routes are identical to the Pagure
- "ui_ns.view_repo_git", with the only exception that "/forks" has been
- added.
- TODO If they ever fix this problem, this route should be replaced with
- simply @pagure_route('ui_ns.view_repo_git')
- - The Pagure app defines a Flask before_request() function that does a
- lot of things, among which to check if there is a "repo" variable in
- the URL and in turn setup some context for the request. This happens
- for *every* request, regardless if it's about a repository or not. The
- rationale is that they rather do it this way than using a separate
- decorator for all the repositories views, because almost all requests
- are about repositories anyway. before_request() will then automatically
- return 404 if a repository does not exist, so we don't need to check
- that here because these views will never be executed.
- - The Pagure before_request() retrieves the repository using the function
- pagure.lib.query.get_authorized_project() which checks for
- authorization and, if the repo is private, it returns 404. So there
- should be no need to check for authorization here.
- """
- project = pagure.lib.query.get_authorized_project(
- flask.g.session, repo, user=username, namespace=namespace)
- repository = flask.g.session.query(model.Repository) \
- .filter(model.Repository.id == project.id) \
- .one_or_none()
- if not repository:
- return ({}, 404)
- # This is a reference to the pygit2 repository object
- git_repository = flask.g.repo_obj
- return activitypub.Repository(project=project, repository=repository,
- git_repository=git_repository)
- @APP.route('/<repo>.git/refs')
- @APP.route('/<namespace>/<repo>.git/refs')
- @APP.route('/forks/<username>/<repo>.git/refs')
- @APP.route('/forks/<username>/<namespace>/<repo>.git/refs')
- def repository_refs(repo, username=None, namespace=None, *args, **kwargs):
- project = pagure.lib.query.get_authorized_project(
- flask.g.session, repo, user=username, namespace=namespace)
- repository = flask.g.session.query(model.Repository) \
- .filter(model.Repository.id == project.id) \
- .one_or_none()
- if not repository:
- return ({}, 404)
- # This is a reference to the pygit2 repository object
- git_repository = flask.g.repo_obj
- return activitypub.Refs(repository=repository,
- git_repository=git_repository)
- @APP.route('/<repo>.git/refs/tags/<tag>')
- @APP.route('/<namespace>/<repo>.git/refs/tags/<tag>')
- @APP.route('/forks/<username>/<repo>.git/refs/tags/<tag>')
- @APP.route('/forks/<username>/<namespace>/<repo>.git/refs/tags/<tag>')
- def repository_refs_tags(repo, tag, username=None, namespace=None, *args, **kwargs):
- project = pagure.lib.query.get_authorized_project(
- flask.g.session, repo, user=username, namespace=namespace)
- repository = flask.g.session.query(model.Repository) \
- .filter(model.Repository.id == project.id) \
- .one_or_none()
- if not repository:
- return ({}, 404)
- # This is a reference to the pygit2 repository object
- git_repository = flask.g.repo_obj
- tag_ref = 'refs/tags/{}'.format(tag)
- if tag_ref not in git_repository.references:
- return ({}, 404)
- return activitypub.TagRef(repository=repository, ref=tag_ref)
- @pagure_route('ui_ns.view_issues')
- def tickettracker(*args, **kwargs):
- username = kwargs.get('username')
- namespace = kwargs.get('namespace')
- repo = kwargs.get('repo')
- project = pagure.lib.query.get_authorized_project(
- flask.g.session, repo, user=username, namespace=namespace)
- if not project:
- return ({}, 404)
- tracker = flask.g.session.query(model.TicketTracker) \
- .filter(model.TicketTracker.id == project.id) \
- .one_or_none()
- if not tracker:
- return ({}, 404)
- return activitypub.TicketTracker(tracker=tracker, project=project)
- @pagure_route('ui_ns.view_issue')
- def ticket(*args, **kwargs):
- username = kwargs.get('username')
- namespace = kwargs.get('namespace')
- repo = kwargs.get('repo')
- issue_id = kwargs.get('issueid')
- repository = pagure.lib.query.get_authorized_project(
- flask.g.session, repo, user=username, namespace=namespace)
- if not repository:
- return ({}, 404)
- ticket = flask.g.session.query(model.Ticket) \
- .filter(model.Ticket.id == issue_id) \
- .filter(model.Ticket.project_id == repository.id) \
- .one_or_none()
- if not ticket:
- return ({}, 404)
- tickettracker = flask.g.session.query(model.TicketTracker) \
- .filter(model.TicketTracker.id == ticket.project.id) \
- .one_or_none()
- if not tickettracker:
- return ({}, 404)
- return activitypub.Ticket(ticket=ticket, tickettracker=tickettracker)
- @pagure_route('ui_ns.view_commit')
- def commit(*args, **kwargs):
- username = kwargs.get('username')
- namespace = kwargs.get('namespace')
- repo = kwargs.get('repo')
- commit_id = kwargs.get('commitid')
- repository = pagure.lib.query.get_authorized_project(
- flask.g.session, repo, user=username, namespace=namespace)
- if not repository:
- return ({}, 404)
- project = flask.g.session \
- .query(model.Project) \
- .filter(model.Project.id == repository.id) \
- .one_or_none()
- # This is a reference to the actual pygit2 repository. It's different
- # from the reference to the Pagure database object. This variable is
- # set by Pagure before_request()
- git_repository = flask.g.repo_obj
- branchname = flask.request.args.get('branch', None)
- if branchname and branchname not in git_repository.listall_branches():
- branchname = None
- try:
- commit = git_repository.get(commit_id)
- except ValueError:
- return ({}, 404)
- if commit is None:
- return ({}, 404)
- if isinstance(commit, pygit2.Blob):
- return ({}, 404)
- if isinstance(commit, pygit2.Tag):
- commit = commit.peel(pygit2.Commit)
- return flask.redirect(
- flask.url_for(
- 'ui_ns.view_commit',
- repo=repository.name,
- username=username,
- namespace=repository.namespace,
- commitid=commit.hex))
- return activitypub.Commit(project, repository, commit)
- @pagure_route('ui_ns.view_tree')
- def branch(*args, **kwargs):
- username = kwargs.get('username')
- namespace = kwargs.get('namespace')
- repo = kwargs.get('repo')
- branch_name = kwargs.get('identifier')
- repository = pagure.lib.query.get_authorized_project(
- flask.g.session, repo, user=username, namespace=namespace)
- if not repository:
- return ({}, 404)
- project = flask.g.session \
- .query(model.Project) \
- .filter(model.Project.id == repository.id) \
- .one_or_none()
- return activitypub.Branch(project, repository, branch_name)
- @pagure_route('ui_ns.request_pull')
- def merge_request(*args, **kwargs):
- username = kwargs.get('username')
- namespace = kwargs.get('namespace')
- repo = kwargs.get('repo')
- request_id = kwargs.get('requestid')
- repository = pagure.lib.query.get_authorized_project(
- flask.g.session, repo, user=username, namespace=namespace)
- if not repository:
- return ({}, 404)
- mergerequest = flask.g.session \
- .query(model.MergeRequest) \
- .filter(model.MergeRequest.id == request_id,
- model.MergeRequest.project_id == repository.id) \
- .one_or_none()
- if not mergerequest:
- return ({}, 404)
- return activitypub.MergeRequest(mergerequest)
- log.info('forgefed plugin registered by Flask.')
- # WebFinger
- #
- # This is primarily used to support other ActivityPub software such as
- # Mastodon that relies on webfinger as a discovery protocol because users
- # use @username@domain when mentioning other users.
- # https://docs.joinmastodon.org/spec/webfinger/
- # -----------------------------------------------------------------------------
- @APP.route('/.well-known/host-meta', methods=['GET'])
- def host_meta():
- return flask.render_template('host-meta.xml', APP_URL=APP_URL)
- @APP.route('/.well-known/webfinger/<path:uri>', methods=['GET'])
- def webfinger_resource(uri):
- """
- Return the webfinger info for account "uri".
- :param uri: The "acct:userpard@host" to search.
- """
- # Only support acct: resources
- # Do we need to support other schemes? WebFinger is neutral regarding the
- # scheme of URI: it could be "acct", "http", "https", "mailto", or some
- # other scheme, but other AcitivityPub instances such as Mastodon only
- # use "acct".
- if not uri.startswith('acct:'):
- return ({}, 404)
- # The "acct" scheme is defined in the spec as
- # "acct" ":" userpart "@" host
- # "host" is the domain where the account is hosted
- # "userpart" contains the localinfo used by the host to retrieve the account
- userpart, host = uri[5:].rsplit('@', 1)
- # Now we find the actual actor's URI.
- # The "userpart" is basically the "preferredUsername" property defined in
- # model.py
- if userpart.startswith('project/'):
- actor_uri = '{}/{}'.format(APP_URL, userpart[8:])
- else:
- actor_uri = '{}/user/{}'.format(APP_URL, userpart)
- return flask.render_template('webfinger.json', subject=uri, actor_uri=actor_uri)
- # NodeInfo
- #
- # This is used for supporting the NodeInfo discoverability protocol.
- # http://nodeinfo.diaspora.software/schema.html
- # There was a proposal for supporting ServiceInfo too. ServiceInfo is a fork of
- # NodeInfo, and should be an improved version of the former. However, the spec
- # has stalled and it's not been developed to completion.
- # -----------------------------------------------------------------------------
- @APP.route('/.well-known/nodeinfo', methods=['GET'])
- def nodeinfo():
- """
- Return the JRD document as dictated by the NodeInfo specification.
- """
- return {
- 'links': [
- {
- 'rel': 'http://nodeinfo.diaspora.software/ns/schema/2.1',
- 'href': '{}{}'.format(APP_URL, flask.url_for('forgefed_ns.nodeinfo2_1'))
- },
- {
- 'rel': 'http://nodeinfo.diaspora.software/ns/schema/2.0',
- 'href': '{}{}'.format(APP_URL, flask.url_for('forgefed_ns.nodeinfo2_0'))
- },
- {
- 'rel': 'http://nodeinfo.diaspora.software/ns/schema/1.1',
- 'href': '{}{}'.format(APP_URL, flask.url_for('forgefed_ns.nodeinfo1_1'))
- },
- {
- 'rel': 'http://nodeinfo.diaspora.software/ns/schema/1.0',
- 'href': '{}{}'.format(APP_URL, flask.url_for('forgefed_ns.nodeinfo1_0'))
- }
- ]
- }
- @APP.route('/nodeinfo/2.1', methods=['GET'])
- def nodeinfo2_1():
- """
- Return the NodeInfo document v2.1
- """
- # In order to get the real number of users of this instance we need to subtract
- # the accounts used for remote users.
- # Use func() to avoid sub-queries, as described here: https://docs.sqlalchemy.org/en/14/orm/query.html?highlight=count#sqlalchemy.orm.Query.count
- total_users = flask.g.session.query(sqlalchemy.func.count(model.Person.id)).scalar()
- return {
- 'version': '2.1',
- 'software': {
- 'name': 'Pagure',
- 'version': pagure.__version__,
- 'repository': 'https://pagure.io/pagure',
- 'homepage': 'https://pagure.io'
- },
- 'protocols': [ 'activitypub', 'forgefed' ],
- 'services': {
- 'inbound': [],
- 'outbound': []
- },
- 'openRegistrations': pagure.config.config.get('ALLOW_USER_REGISTRATION', False),
- 'usage': {
- 'users': {
- 'total': total_users,
- # 'activeHalfyear': 0,
- # 'activeMonth': 0
- },
- # 'localPosts': 0,
- # 'localComments': 0
- },
- 'metadata': {}
- }
- @APP.route('/nodeinfo/2.0', methods=['GET'])
- def nodeinfo2_0():
- """
- Return the NodeInfo document v2.0
- """
- # In order to get the real number of users of this instance we need to subtract
- # the accounts used for remote users.
- # Use func() to avoid sub-queries, as described here: https://docs.sqlalchemy.org/en/14/orm/query.html?highlight=count#sqlalchemy.orm.Query.count
- total_users = flask.g.session.query(sqlalchemy.func.count(model.Person.id)).scalar()
- return {
- 'version': '2.0',
- 'software': {
- 'name': 'Pagure',
- 'version': pagure.__version__
- },
- 'protocols': [ 'activitypub', 'forgefed' ],
- 'services': {
- 'inbound': [],
- 'outbound': []
- },
- 'openRegistrations': pagure.config.config.get('ALLOW_USER_REGISTRATION', False),
- 'usage': {
- 'users': {
- 'total': total_users,
- # 'activeHalfyear': 0,
- # 'activeMonth': 0
- },
- # 'localPosts': 0,
- # 'localComments': 0
- },
- 'metadata': {}
- }
- @APP.route('/nodeinfo/1.1', methods=['GET'])
- def nodeinfo1_1():
- """
- Return the NodeInfo document v1.1
- """
- # In order to get the real number of users of this instance we need to subtract
- # the accounts used for remote users.
- # Use func() to avoid sub-queries, as described here: https://docs.sqlalchemy.org/en/14/orm/query.html?highlight=count#sqlalchemy.orm.Query.count
- total_users = flask.g.session.query(sqlalchemy.func.count(model.Person.id)).scalar()
- return {
- 'version': '1.1',
- 'software': {
- 'name': 'Pagure',
- 'version': pagure.__version__
- },
- 'protocols': {
- 'inbound': [ 'activitypub', 'forgefed' ],
- 'outbound': [ 'activitypub', 'forgefed' ]
- },
- 'services': {
- 'inbound': [],
- 'outbound': []
- },
- 'openRegistrations': pagure.config.config.get('ALLOW_USER_REGISTRATION', False),
- 'usage': {
- 'users': {
- 'total': total_users,
- # 'activeHalfyear': 0,
- # 'activeMonth': 0
- },
- # 'localPosts': 0,
- # 'localComments': 0
- },
- 'metadata': {}
- }
- @APP.route('/nodeinfo/1.0', methods=['GET'])
- def nodeinfo1_0():
- """
- Return the NodeInfo document v1.0
- """
- # In order to get the real number of users of this instance we need to subtract
- # the accounts used for remote users.
- # Use func() to avoid sub-queries, as described here: https://docs.sqlalchemy.org/en/14/orm/query.html?highlight=count#sqlalchemy.orm.Query.count
- total_users = flask.g.session.query(sqlalchemy.func.count(model.Person.id)).scalar()
- return {
- 'version': '1.0',
- 'software': {
- 'name': 'Pagure',
- 'version': pagure.__version__
- },
- 'protocols': {
- 'inbound': [ 'activitypub', 'forgefed' ],
- 'outbound': [ 'activitypub', 'forgefed' ]
- },
- 'services': {
- 'inbound': [],
- 'outbound': []
- },
- 'openRegistrations': pagure.config.config.get('ALLOW_USER_REGISTRATION', False),
- 'usage': {
- 'users': {
- 'total': total_users,
- # 'activeHalfyear': 0,
- # 'activeMonth': 0
- },
- # 'localPosts': 0,
- # 'localComments': 0
- },
- 'metadata': {}
- }
- # Routes used to interact with remote objects of the federation, when we
- # cannot reuse another Pagure route.
- # -----------------------------------------------------------------------------
- @APP.route('/federation', methods=['GET'])
- @requires_login
- def federation():
- """
- Entry page for federation.
- """
- person = flask.g.session \
- .query(model.Person) \
- .filter(model.Person.id == flask.g.fas_user.id) \
- .one_or_none()
- if not person:
- return flask.redirect('/')
- # Retrieve feeds of the current user
- items = flask.g.session \
- .query(model.Feed) \
- .filter(model.Feed.actor_uri == person.uri) \
- .order_by(model.Feed.created.desc()) \
- .all()
- # A feed "content" property contains a serialized Python dictionary.
- # We convert strings back to dictionary before rendering the template.
- feed_items = [
- { 'created': feed.created, 'content': json.loads(feed.content) }
- for feed in items
- ]
- return flask.render_template('federation/feeds.html', feeds=feed_items)
- @APP.route('/federation/activity/<id>', methods=['GET'])
- def federation_activity(id):
- """
- Return the JSON representation of Activities created by this instance.
- """
- resource = flask.g.session \
- .query(model.Resource) \
- .filter(model.Resource.uri == '{}/federation/activity/{}'.format(APP_URL, id)) \
- .one_or_none()
- if not resource:
- return ({}, 404)
- activity = json.loads(resource.document)
- return activity
- @APP.route('/federation/followers', methods=['GET'])
- @requires_login
- def federation_followers():
- user = flask.g.session \
- .query(model.Person) \
- .filter(model.Person.id == flask.g.fas_user.id) \
- .one_or_none()
- if not user:
- return flask.redirect(flask.url_for('forgefed_ns.federation'))
- person = activitypub.fetch(user.uri)
- # Retrieve "following" for the current user
- items = flask.g.session \
- .query(model.Collection, model.Resource) \
- .join(model.Resource, model.Collection.item == model.Resource.uri) \
- .filter(model.Collection.uri == person['followers']) \
- .all()
- items = [ json.loads(resource.document) for collection, resource in items ]
- items = sorted(items, key=lambda i: i['name'].casefold())
- return flask.render_template('federation/followers.html', followers=items)
- @APP.route('/federation/following', methods=['GET'])
- @requires_login
- def federation_following():
- user = flask.g.session \
- .query(model.Person) \
- .filter(model.Person.id == flask.g.fas_user.id) \
- .one_or_none()
- if not user:
- return flask.redirect(flask.url_for('forgefed_ns.federation'))
- person = activitypub.fetch(user.uri)
- # Retrieve "following" for the current user
- items = flask.g.session \
- .query(model.Collection, model.Resource) \
- .join(model.Resource, model.Collection.item == model.Resource.uri) \
- .filter(model.Collection.uri == person['following']) \
- .all()
- items = [ json.loads(resource.document) for collection, resource in items ]
- items = sorted(items, key=lambda i: i['name'].casefold())
- return flask.render_template('federation/following.html', following=items)
- @APP.route('/federation/search', methods=['GET'])
- @requires_login
- def federation_search():
- uri = flask.request.args.get('uri')
- result = None
- # Search for an object
- if uri:
- result = activitypub.fetch(uri)
- return flask.render_template(
- 'federation/search.html',
- uri=uri,
- result=result)
- @APP.route('/federation/follow', methods=['GET'])
- @requires_login
- def federation_follow():
- remote_actor_uri = flask.request.args.get('actor_uri')
- # The user that clicked the "Follow" button
- person = flask.g.session \
- .query(model.Person) \
- .filter(model.Person.id == flask.g.fas_user.id) \
- .one_or_none()
- # The user that clicked "Follow" doesn't exist?!
- if not person:
- return flask.redirect(flask.url_for('forgefed_ns.federation'))
- person_jsonld = activitypub.fetch(person.local_uri)
- remote_actor = activitypub.fetch(remote_actor_uri)
- if not remote_actor:
- raise Exception('Could not fetch remote actor.')
- flask.g.session.add(model.Feed(person.uri, feeds.follow(person_jsonld, remote_actor)))
- # Send a notification because Pagure doesn't have any notification for
- # this event.
- pagure.lib.notify.log(None, 'forgefed.follow', {
- 'follower': person_jsonld,
- 'followed': remote_actor})
- flask.g.session.commit()
- return flask.redirect(flask.url_for('forgefed_ns.federation'))
- @APP.route('/federation/submit_ticket', methods=['GET'])
- @requires_login
- def federation_submit_ticket():
- # The URL of the remote tracker (actor)
- tickettracker_uri = flask.request.args.get('actor_uri')
- if not tickettracker_uri:
- return flask.redirect(flask.url_for('ui_ns.index'))
- # Retrieve the remote actor
- tickettracker_jsonld = activitypub.fetch(tickettracker_uri)
- if not tickettracker_jsonld:
- log.debug('Cannot submit Ticket because cannot fetch remote TicketTracker.')
- flask.abort()
- # Check if the TicketTracker already exists
- tickettracker = model.from_uri(flask.g.session, tickettracker_uri)
- # TicketTracker already exists:
- if tickettracker:
- if not isinstance(tickettracker, model.TicketTracker):
- log.debug('{} is not a valid TicketTracker.'.format(tickettracker_uri))
- flask.abort()
- return flask.redirect(flask.url_for('ui_ns.new_issue',
- repo=tickettracker.name,
- username=tickettracker.user.username if tickettracker.is_fork else None,
- namespace=tickettracker.namespace))
- # TicketTracker doesn't exist
- log.debug('Creating TicketTracket for remote object {}'.format(tickettracker_uri))
- # The user that clicked the "Open ticket" button
- person = flask.g.session \
- .query(model.Person) \
- .filter(model.Person.id == flask.g.fas_user.id) \
- .one_or_none()
- if not person:
- return flask.redirect(flask.url_for('forgefed_ns.federation'))
- # Create a local user for the project because in Pagure a project must
- # have a user ID
- forgefed_user = test_or_set_forgefed_user(flask.g.session)
- project_name = 'forgefed:tracker:{}@{}'.format(
- model.safe_uri(tickettracker_jsonld['name']),
- model.safe_uri(urllib.parse.urlparse(tickettracker_uri).netloc))
- # Create the project in the Pagure database.
- # This function will create the item in the database, and then will
- # start a new async task to create the actual .git folder.
- task = pagure.lib.query.new_project(
- session=flask.g.session,
- user=forgefed_user.username,
- name=project_name,
- blacklist=[],
- allowed_prefix=[],
- repospanner_region=None,
- description='Remote tracker.',
- url=tickettracker_uri,
- avatar_email=None,
- parent_id=None,
- add_readme=False,
- namespace=None,
- user_ns=False,
- ignore_existing_repo=False,
- private=False,
- )
- # We need to wait for the project to be created, then we redirect to the
- # project's issues page
- task_result = task.wait(timeout=None, interval=0.5)
- tickettracker = flask.g.session \
- .query(model.TicketTracker) \
- .filter(model.TicketTracker.name == project_name) \
- .one_or_none()
- # Disable pull requests for this project, since it's only used as a
- # ticket tracker
- tickettracker.settings = {
- **tickettracker.settings,
- 'issue_tracker': True,
- 'project_documentation': False,
- 'pull_requests': False,
- 'pull_request_access_only': True,
- 'issue_tracker_read_only': False,
- }
- # The tracker that we've just created is only used to interact with a
- # remote one. It's not a "real" local tracker by a local user, so we
- # create a SameAs relation in the graph.
- #tracker_url = '{}/{}/issues'.format(APP_URL, user.username)
- tickettracker.remote_uri = tickettracker_uri
- # Make sure that this User is following the tracker, so that per will receive
- # notifications
- activity = activitypub.Activity(
- type = 'Follow',
- actor = person.uri,
- object = tickettracker.remote_uri,
- to = tickettracker.remote_uri)
- activity.distribute()
- flask.g.session.commit()
- # Redirect to the "new issue" page of the local tracker, where the user can
- # create a new issue for the remote tracker.
- return flask.redirect(flask.url_for('ui_ns.new_issue', repo=project_name))
- @APP.route('/federation/ticket/<issue_uid>/comments', methods=['GET'])
- def federation_ticket_comments(issue_uid):
- """
- Return the Collection containing a ticket's comments.
- This route exists because pagure does not have any route defined for comments.
- The path of a comment in pagure is /<repo>/issue/<issueid>#comment-<commend_id>
- but the fragment of the URL (after the # symbol) is not sent to the server.
- :param issue_uid: The unique ID defined by pagure during ticket creation. This
- is used instead of the default key which is (project_id, issue_id).
- """
- collection_uri = APP_URL + flask.request.path
- return activitypub.OrderedCollection(collection_uri)
- @APP.route('/federation/ticket/<issue_uid>/comments/<int:page>', methods=['GET'])
- def federation_ticket_comments_page(issue_uid):
- """
- Return the CollectionPage containing a ticket's comments.
- :param issue_uid: The unique ID defined by pagure during ticket creation. This
- is used instead of the default key which is (project_id, issue_id).
- :param page: The page of the collection to show.
- """
- page_uri = APP_URL + flask.request.path
- collection_uri = page_uri.rsplit('/', 1)[0]
- # Retrieve items
- items = flask.g.session \
- .query(model.TicketComment) \
- .filter(model.TicketComment.issue_uid == issue_uid) \
- .offset(settings.COLLECTION_SIZE * page) \
- .limit(settings.COLLECTION_SIZE) \
- .all()
- items_ids = [ result.local_uri for result in items ]
- return {
- '@context': activitypub.jsonld_context,
- 'type': 'OrderedCollectionPage',
- 'id': page_uri,
- 'partOf': collection_uri,
- 'orderedItems': items_ids }
- @APP.route('/federation/ticket_comment/<comment_id>', methods=['GET'])
- def federation_ticket_comment(comment_id):
- """
- Return the JSONLD of a ticket comment. This is required because Pagure
- doesn't have any route dedicated to show a single comment.
- :param comment_id: The comment ID defined by pagure during comment creation.
- This is the default primary key of the comment and is unique across all
- local issues.
- """
- comment = flask.g.session \
- .query(model.TicketComment) \
- .filter(model.TicketComment.id == comment_id) \
- .one_or_none()
- return activitypub.TicketComment(comment=comment)
- @APP.route('/federation/fork', methods=['GET'])
- @requires_login
- def federation_fork():
- # The URL of the remote tracker (actor)
- repository_uri = flask.request.args.get('repository_uri')
- if not repository_uri:
- return flask.redirect(flask.url_for('ui_ns.index'))
- # The user that clicked the "Fork" button
- person = flask.g.session \
- .query(model.Person) \
- .filter(model.Person.id == flask.g.fas_user.id) \
- .one_or_none()
- if not person:
- return flask.redirect(flask.url_for('forgefed_ns.federation'))
- # Retrieve the remote actor
- repository_jsonld = activitypub.fetch(repository_uri)
- if not repository_jsonld:
- log.debug('Cannot fork because cannot fetch remote Repository.')
- flask.abort()
- # Check if the Repository already exists
- repository = model.from_uri(flask.g.session, repository_uri)
- # Repository already exists?
- if repository:
- if not isinstance(repository, model.Repository):
- log.debug('{} is not a valid Repository.'.format(repository_uri))
- flask.abort()
- else:
- log.debug('Creating Repository for remote object {}'.format(repository_uri))
- # Create a local user for the project because in Pagure a project must
- # have a user ID
- forgefed_user = test_or_set_forgefed_user(flask.g.session)
- project_name = 'forgefed:repository:{}@{}'.format(
- model.safe_uri(repository_jsonld['name']),
- model.safe_uri(urllib.parse.urlparse(repository_uri).netloc))
- # Create the project in the Pagure database.
- # This function will create the item in the database, and then will
- # start a new async task to create the actual .git folder.
- task = pagure.lib.query.new_project(
- session=flask.g.session,
- user=forgefed_user.username,
- name=project_name,
- blacklist=[],
- allowed_prefix=[],
- repospanner_region=None,
- description='Remote repository.',
- url=repository_uri,
- avatar_email=None,
- parent_id=None,
- add_readme=False,
- mirrored_from=repository_uri,
- namespace=None,
- user_ns=False,
- ignore_existing_repo=False,
- private=False,
- )
- # We need to wait for the project to be created, then we redirect to the
- # project's issues page
- task_result = task.wait(timeout=None, interval=0.5)
- repository = flask.g.session \
- .query(model.Repository) \
- .filter(model.Repository.name == project_name,
- model.Repository.is_fork == False) \
- .one_or_none()
- if not repository:
- log.critical('Project repository was not created before forking.')
- flask.abort(500)
- # Disable tickets for this project, since it's only used as a mirror
- repository.settings = {
- **repository.settings,
- 'issue_tracker': False,
- 'project_documentation': False,
- 'pull_requests': True,
- 'pull_request_access_only': True,
- 'issue_tracker_read_only': True,
- }
- # The repository that we've just created is only used to interact with a
- # remote one. It's not a "real" local repository by a local user, so we
- # create a SameAs relation in the graph.
- repository.remote_uri = repository_uri
- # Redirect to the user fork if the user already forked this repository
- project_fork = pagure.lib.query._get_project(
- session=flask.g.session,
- name=repository.name,
- user=person.username,
- namespace=repository.namespace)
- if project_fork:
- return flask.redirect(
- flask.url_for(
- 'ui_ns.view_repo',
- repo=project_fork.name,
- username=person.username,
- namespace=project_fork.namespace))
- log.debug('Forking {} for user {}'.format(repository.uri, person.uri))
- # TODO This mirror is happening synchronously. How can I use Pagure "wait
- # screen"?
- if repository.is_remote:
- pagure.lib.git.mirror_pull_project(flask.g.session, repository)
- task = pagure.lib.query.fork_project(
- session=flask.g.session, repo=repository, user=person.username)
- task_result = task.wait(timeout=None, interval=0.5)
- # Make sure that this User is following the tracker, so that per will receive
- # notifications
- activitypub.Activity(
- type = 'Follow',
- actor = person.uri,
- object = repository.remote_uri,
- to = repository.remote_uri
- ).distribute()
- flask.g.session.commit()
- # Redirect to the user fork
- return flask.redirect(
- flask.url_for(
- 'ui_ns.view_repo',
- repo=repository.name,
- username=person.username,
- namespace=repository.namespace))
- @APP.route('/federation/mergerequest/<mergerequest_uid>/comments', methods=['GET'])
- def federation_mergerequest_comments(mergerequest_uid):
- """
- Return the Collection containing a MergeRequest's comments.
- This route exists because pagure does not have any route defined for comments.
- :param mergerequest_uid: The unique ID defined by Pagure when it creates a
- PullRequest. This is used instead of the default primary_key which is the
- tuple (id, project_id).
- """
- collection_uri = APP_URL + flask.request.path
- return activitypub.OrderedCollection(collection_uri)
- @APP.route('/federation/mergerequest/<mergerequest_uid>/comments/<int:page>', methods=['GET'])
- def federation_mergerequest_comments_page(mergerequest_uid):
- """
- Return the CollectionPage containing a MergeRequest's comments.
- :param mergerequest_uid: The unique ID defined by Pagure when it creates a
- PullRequest. This is used instead of the default primary_key which is the
- tuple (id, project_id).
- :param page: The page of the collection to show.
- """
- page_uri = APP_URL + flask.request.path
- collection_uri = page_uri.rsplit('/', 1)[0]
- # Retrieve items
- items = flask.g.session \
- .query(model.MergeRequestComment) \
- .filter(model.MergeRequestComment.pull_request_uid == mergerequest_uid) \
- .offset(settings.COLLECTION_SIZE * page) \
- .limit(settings.COLLECTION_SIZE) \
- .all()
- items_ids = [ result.local_uri for result in items ]
- return {
- '@context': activitypub.jsonld_context,
- 'type': 'OrderedCollectionPage',
- 'id': page_uri,
- 'partOf': collection_uri,
- 'orderedItems': items_ids }
- @APP.route('/federation/mergerequest_comment/<comment_id>', methods=['GET'])
- def federation_mergerequest_comment(comment_id):
- """
- Return the JSONLD of a MergeRequest comment. This is required because Pagure
- doesn't have any route dedicated to show a single comment.
- :param comment_id: The comment ID defined by Pagure during comment creation.
- This is the default primary key of the comment and is unique across all
- local pull-requests.
- """
- comment = flask.g.session \
- .query(model.MergeRequestComment) \
- .filter(model.MergeRequestComment.id == comment_id) \
- .one_or_none()
- return activitypub.MergeRequestComment(comment=comment)
- # Repository
- # ----------
- @APP.route('/federation/tag/<id>', methods=['GET'])
- def project_tag(id):
- """
- Display a Tag object.
- """
- tag = flask.g.session \
- .query(model.Tag) \
- .filter(model.Tag.id == id) \
- .one_or_none()
- if not tag:
- return ({}, 404)
- return activitypub.Tag(tag=tag)
- # Actor
- # -----
- @APP.route('/<path:actor>/key.pub', methods=['GET'])
- def actor_key(actor):
- """
- This object represents the public GPG key used to sign HTTP requests.
- """
- actor_path = '/' + actor
- actor_uri = APP_URL + '/' + actor
- actor = model.from_path(flask.g.session, actor_path)
- key_uri = APP_URL + flask.request.path
- if not actor:
- return ({}, 404)
- # Create key if it doesn't exist
- model.GpgKey.test_or_set(flask.g.session, actor_uri, key_uri)
- # Get the key
- key = flask.g.session.query(model.GpgKey) \
- .filter(model.GpgKey.uri == key_uri) \
- .one_or_none()
- return activitypub.CryptographicKey(key=key)
- @APP.route('/<path:actor>/sshkey/<path:key>', methods=['GET'])
- def actor_ssh_key(actor, key):
- """
- Return an Actor's SSH key.
- :param key: An SSH key identifier as created by Pagure when users insert their
- public keys in the user settings. This corresponds to the SSHKey.ssh_search_key
- property in the Pagure model.
- """
- actor_path = '/' + actor
- actor_uri = APP_URL + '/' + actor
- actor = model.from_path(flask.g.session, actor_path)
- key_uri = APP_URL + flask.request.path
- if not actor:
- return ({}, 404)
- # Get the key
- key = flask.g.session \
- .query(model.SshKey) \
- .filter(model.SshKey.ssh_search_key == key) \
- .one_or_none()
- if not key:
- return ({}, 404)
- return activitypub.SshKey(key=key)
- @APP.route('/<path:project>/role/<role>', methods=['GET'])
- def project_roles(project, role):
- """
- Returns a Role.
- """
- role = flask.g.session \
- .query(pagure.lib.model.AccessLevels) \
- .filter(pagure.lib.model.AccessLevels.access == role) \
- .one_or_none()
- if not role:
- log.debug('The Role {} does not exist.'.format(role))
- return ({}, 404)
- project_path = '/' + project
- project_uri = APP_URL + project_path
- project = model.from_uri(flask.g.session, project_uri)
- if not project:
- log.debug('The Project {} does not exist.'.format(project_uri))
- return ({}, 404)
- return activitypub.Role(project=project, role=role)
- @APP.route('/<path:actor>/inbox', methods=['GET'])
- def actor_inbox(actor):
- """
- Returns an Actor's INBOX.
- This should only be called by the Actors who want to read their own INBOX.
- """
- return ({}, 501) # 501 Not Implemented. TODO implement C2S.
- @APP.route('/<path:actor>/outbox', methods=['GET'])
- def actor_outbox(actor, page=None):
- """
- Returns an Actor's OUTBOX.
- This should only be called by the Actors who want to read their own OUTBOX.
- """
- # TODO Show only "Public" OUTBOX
- # https://www.w3.org/TR/activitypub/#public-addressing
- return ({}, 501) # 501 Not Implemented. TODO implement C2S.
- @APP.route('/<path:actor>/inbox', methods=['POST'])
- def actor_receive(actor):
- """
- Somebody is sending a message to an actor's INBOX.
- """
- actor_path = '/' + actor
- actor_uri = APP_URL + '/' + actor
- # Retrieve the ActivityPub Activity from the HTTP request body. The
- # Activity is expected to be a JSON object.
- # https://flask.palletsprojects.com/en/1.1.x/api/#flask.Request.get_json
- activity = flask.request.get_json()
- activity = activitypub.Activity.from_dict(activity)
- # Verify the HTTP signature of the incoming request. The HTTP POST request
- # must have been signed with the Actor public key.
- class SignatureMismatch(Exception):
- def __init__(self, key_id, actor_id):
- """
- :param key_id: The key ID in the HTTP signature
- :param actor_id: The ID of the Activity Actor.
- """
- self.key_id = key_id
- self.actor_id = actor_id
- def __str__(self):
- return 'HTTP request signed by a different Actor:\n' + \
- ' key_id: {}\n'.format(self.key_id) + \
- ' Actor: {}\n'.format(self.actor_id)
- def key_resolver(key_id, algorithm):
- # Fetch the remote Actor's key
- remote_actor_key = activity.node('actor').node('publicKey')
- if key_id != activity['actor']['id']:
- raise SignatureMismatch(key_id, activity['actor']['id'])
- # .encode() will encode the UTF-8 string to bytes. We need this because
- # the 'publicKeyPem' retrieved from the ActivityPub JSON-LD is in UTF-8
- # format but the requests_http_signature module requires raw bytes data.
- return remote_actor_key['publicKeyPem'].encode()
- signature_verified = False
- try:
- requests_http_signature.HTTPSignatureAuth.verify(
- flask.request, key_resolver=key_resolver, scheme='Signature')
- except SignatureMismatch:
- """
- If the HTTP Signature key_id does not match the Actor ID, it means that
- the Activity was POSTed by a different Actor than the Activity actual Actor.
- This probably means that the Activity was forwarded (unless it was forged!).
- """
- pass
- except Exception as e:
- log.error(e)
- log.error('Could not verify HTTP signature for incoming Activity'.format(activity['id']))
- return ({}, 400) # Bad Request
- else:
- log.debug('HTTP signature for incoming Activity {} verified.'.format(activity['id']))
- signature_verified = True
- # Schedule a task to process the incoming activity asynchronously
- tasks.delivery.validate_incoming_activity.delay(actor_uri, activity, signature_verified)
- return ({}, 202) # 202 Accepted
- @APP.route('/<path:actor>/outbox', methods=['POST'])
- def actor_send(actor):
- """
- An Actor is trying to POST an Activity to its OUTBOX.
- This should only be called by an Actor's client that want to send out a new
- Activity.
- """
- # TODO
- log.debug('Implement bearer token authentication for 3rd party clients')
- return ({}, 501) # 501 Not Implemented
- actor_uri = APP_URL + '/' + actor
- # Retrieve the ActivityPub Activity from the HTTP request body. The
- # Activity is expected to be a JSON object.
- # https://flask.palletsprojects.com/en/1.1.x/api/#flask.Request.get_json
- activity = flask.request.get_json()
- # Schedule a task to process the incoming activity asynchronously
- tasks.delivery.validate_outgoing_activity.delay(actor_uri, activity)
- @APP.route('/<path:actor>/followers', methods=['GET'])
- def actor_followers(actor):
- """
- Show the followers of an actor.
- """
- actor_path = '/' + actor
- actor = model.from_path(flask.g.session, actor_path)
- if not actor:
- return ({}, 404)
- collection_uri = APP_URL + flask.request.path
- return activitypub.OrderedCollection(collection_uri=collection_uri)
- @APP.route('/<path:actor>/followers/<int:page>', methods=['GET'])
- def actor_followers_page(actor, page):
- """
- Show the followers of an actor.
- """
- page_uri = APP_URL + flask.request.path
- collection_uri = page_uri.rsplit('/', 1)[0]
- # Retrieve items
- items = flask.g.session \
- .query(model.Collection) \
- .filter(model.Collection.uri == collection_uri) \
- .order_by(model.Collection.added.desc()) \
- .offset(settings.COLLECTION_SIZE * page) \
- .limit(settings.COLLECTION_SIZE) \
- .all()
- items_uri = [ result.item for result in items ]
- return activitypub.OrderedCollectionPage(collection_uri=collection_uri,
- page_uri=page_uri,
- items=items_uri)
- @APP.route('/<path:actor>/following', methods=['GET'])
- def actor_following(actor):
- """
- Show the actors that an actor is following.
- """
- actor_path = '/' + actor
- actor = model.from_path(flask.g.session, actor_path)
- if not actor:
- return ({}, 404)
- collection_uri = APP_URL + flask.request.path
- return activitypub.OrderedCollection(collection_uri=collection_uri)
- @APP.route('/<path:actor>/following/<int:page>', methods=['GET'])
- def actor_following_page(actor, page):
- """
- Show the actors that an actor is following.
- """
- page_uri = APP_URL + flask.request.path
- collection_uri = page_uri.rsplit('/', 1)[0]
- # Retrieve items
- items = flask.g.session \
- .query(model.Collection) \
- .filter(model.Collection.uri == collection_uri) \
- .order_by(model.Collection.added.desc()) \
- .offset(settings.COLLECTION_SIZE * page) \
- .limit(settings.COLLECTION_SIZE) \
- .all()
- items_uri = [ result.item for result in items ]
- return activitypub.OrderedCollectionPage(collection_uri=collection_uri,
- page_uri=page_uri,
- items=json.dumps(items_uri))
- log.debug('forgefed plugin initialized.')
|