123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687 |
- # -*- coding: utf-8 -*-
- """
- (c) 2020 - Copyright ...
-
- Authors:
- zPlus <zplus@peers.community>
-
- Notes:
- Useful documentation for SQLAlchemy ORM:
- https://docs.sqlalchemy.org/en/13/orm/tutorial.html
- https://docs.sqlalchemy.org/en/13/orm/basic_relationships.html
- """
- import copy
- import datetime
- import functools
- import logging
- import json
- import pagure.config
- import pagure.lib.model
- import pagure.lib.query
- import random
- import rdflib
- import string
- import urllib
- from . import activitypub
- from . import graph
- from . import settings
- from . import tasks
- # The app URL defined in the Pagure configuration, eg. "https://example.org/"
- # We need this for generating IDs.
- APP_URL = pagure.config.config['APP_URL'].rstrip('/')
- assert APP_URL and len(APP_URL) > 0, 'APP_URL missing from Pagure configuration.'
- log = logging.getLogger(__name__)
- def new_random_id(length=32):
- """
- Generate a random string to use as an Activity ID when a new one is
- created.
- """
-
- symbols = string.ascii_lowercase + string.digits
- return ''.join([ random.choice(symbols) for i in range(length) ])
- def format_datetime(dt):
- """
- This function is used to format a datetime object into a string that is
- suitable for publishing in Activities.
- """
-
- return dt.replace(microsecond=0) \
- .replace(tzinfo=datetime.timezone.utc) \
- .isoformat()
- def from_uri(pagure_db, uri):
- """
- In ActivityPub, objects' ID are URI and occasionally we need to retrieve an
- object from the (pagure) database given only its URI. If pagure were using
- a graph as a database, this would be trivial (just query "DESCRIBE <uri>").
- Also, the plugin does not mirror the entire pagure database into a graph;
- objects' documents (the JSON-LD that is returned when GETting) are created
- "on the fly" for every request. We could add the pagure ID to the JSON-LD
- object, but it would be very inelegant and hacky. We could add URIs to the
- pagure database, but it would be very brittle and would also require to
- change the pagure database schema. So we need a way, given only a URI, to
- reverse it and get the object in the database. This function does that.
-
- NOTES: This function is for *local* users only. It return an SQLAlchemy
- object given an ActivityPub URI.
-
- :param pagure_db: A session context of the pagure database.
-
- :param uri: The URI to reverse.
- """
-
- log.debug('from_uri: {}'.format(uri))
-
- # Fetch the JSON-LD document of the object.
- # Remember: we do not store the pagure objects in the graph, so we must
- # HTTP-GET the URI.
- object = activitypub.fetch(uri)
-
- uri = urllib.parse.urlsplit(uri)
-
- if object['type'] == 'Person':
- return pagure_db.query(Person) \
- .filter(Person.user == object['preferredUsername']) \
- .one_or_none()
-
- if object['type'] == 'Project':
- components = uri.path.lstrip('/').split('/')
-
- repo_username = None
- repo_namespace = None
- repo_name = None
-
- if len(components) == 1:
- repo_name = components[0]
- if len(components) == 2:
- repo_namespace = components[0]
- repo_name = components[1]
- if len(components) == 3:
- repo_username = components[1]
- repo_name = components[2]
- if len(components) == 4:
- repo_username = components[1]
- repo_namespace = components[2]
- repo_name = components[3]
-
- project = pagure_db.query(Projects).filter(Projects.name == repo_name)
-
- if repo_username:
- project = project.filter(Projects.is_fork == True) \
- .filter(Projects.user.has(Person.user == repo_username))
- else:
- project = project.filter(Projects.is_fork == False)
-
- if repo_namespace:
- project = project.filter(Projects.namespace == repo_namespace)
-
- return project.one_or_none()
-
- if object['type'] == 'Ticket':
- # Ticket "context" contains a link to the Project URI
- project = from_uri(pagure_db, object['context'])
-
- # Ticket ID from the URI
- ticket_id = uri.path.rsplit('/', 1)[-1]
-
- return pagure_db.query(Ticket) \
- .filter(Ticket.id == ticket_id,
- Ticket.project_id == project.id) \
- .one_or_none()
-
- if object['type'] == 'Note':
- if uri.path.startswith('/federation/ticket_comment/'):
- comment_id = uri.path.rsplit('/', 1)[-1]
- return pagure_db.query(TicketComment) \
- .filter(TicketComment.id == comment_id) \
- .one_or_none()
-
- # No object found
- return None
- def from_local_uri(pagure_db, uri):
- # Check if the URI is an actual local object
- if not uri.startswith(APP_URL):
- return None
-
- return from_uri(pagure_db, uri)
- def from_remote_uri(pagure_db, forgefed_graph, uri):
- # Check if the URI is an actual remote object
- if uri.startswith(APP_URL):
- return None
-
- # Get the URI of the local object that maps the remote object
- local_uri = forgefed_graph.value(
- predicate = rdflib.OWL.sameAs,
- object = rdflib.URIRef(uri))
-
- if not local_uri:
- return None
-
- # Cast type URIRef() to string
- local_uri = str(local_uri)
-
- return from_uri(pagure_db, local_uri)
- def test_or_set_remote_actor(pagure_db, forgefed_graph, uri):
- """
- We want to use the existing pagure UI to collaborate with remote users, for
- example to hold a conversation on a Ticket. Problem is, the pagure database
- model is built around *local* users that have IDs and relations with other
- schemas. So, we create a local mock user that represents a remote one, and
- call it user@domain.
-
- :param pagure_db: A session to the pagure database.
- :param forgefed_graph: A session to the forgefed graph.
- :param uri: The URI of the remote actor.
- """
-
- # Fetch JSONLD of the remote actor
- actor = activitypub.fetch(uri)
- webfinger = '{}@{}'.format(actor['preferredUsername'], urllib.parse.urlparse(uri).netloc)
-
- # This will create a new user in the Pagure database if it doesn't exist
- user = pagure.lib.query.set_up_user(
- session = pagure_db,
- username = webfinger.replace('/', '+'),
- fullname = actor['name'],
- default_email = webfinger)
-
- # Return a Person object instead of the pagure User class
- person = pagure_db.query(Person) \
- .filter(Person.id == user.id) \
- .one_or_none()
-
- # Set a owl:sameAs link in the forgefed graph, so that we know that this
- # person is only used to represent a remote user.
- forgefed_graph.set((rdflib.URIRef(person.local_uri),
- rdflib.OWL.sameAs,
- rdflib.URIRef(uri)))
-
- return person
- def test_or_set_remote_comment(pagure_db, forgefed_graph, uri):
- """
- This is the same as test_or_set_remote_actor() but for comments.
-
- :param pagure_db: A session to the pagure database.
- :param forgefed_graph: A session to the forgefed graph.
- :param uri: The URI of the remote Note.
- """
-
- # If the URI of the comment is a URI to the local instance, we don't
- # need to do anything because the pagure database already contains the
- # comment.
- if uri.startswith(APP_URL):
- return
-
- # Fetch JSONLD of the remote Note
- note = activitypub.fetch(uri)
-
- # Check if we already have a local object for this remote comment
- if (None, rdflib.OWL.sameAs, rdflib.URIRef(uri)) in forgefed_graph:
- log.debug('The note {} is already stored in the database. Will not create a new one.'.format(uri))
- return
-
- # Otherwise we create a local object in the pagure database for the remote Note...
-
- # If the "context" of the Note (which can be a Ticker or a MergeRequest) is
- # a local URL, it means somebody has created a Note for an object in our
- # database. This is the case for example when a remote user is contributing
- # a comment to a local project.
- if note['context'].startswith(APP_URL):
-
- # Get the database object
- context = from_local_uri(pagure_db, note['context'])
-
- # This is a new Note for a local Ticket
- if isinstance(context, Ticket):
- author = test_or_set_remote_actor(pagure_db, forgefed_graph, note['attributedTo'])
-
- # Create the new comment to the ticket
- pagure.lib.query.add_issue_comment(
- session = pagure_db,
- issue = context,
- comment = note['content'],
- user = author.username)
-
- #if isinstance(context, MergeRequest):
- # TODO
- # pass
-
- # If the "context" of the Note is not a local ticket or a local MR, this
- # note was created for a remote object. This is the case for example when a
- # user has sent a comment to a remote ticket, and we have received the
- # Activity because we are following that ticket.
- else:
-
- # Check if there is any local ticket or MR in the pagure database that
- # is used to track a remote object
- context = from_remote_uri(pagure_db, forgefed_graph, note['context'])
-
- # This is a new Note for a remote Ticket
- if isinstance(context, Ticket):
- author = test_or_set_remote_actor(pagure_db, forgefed_graph, note['attributedTo'])
-
- # Create the new comment to the ticket
- pagure.lib.query.add_issue_comment(
- session = pagure_db,
- issue = context,
- comment = note['content'],
- user = author.username)
- def action(func):
- """
- This function creates a decorator to be applied to the methods of class
- Actor. It represents an ActivityStream Action.
- The decorator will first execute the decorated function, and then schedule
- the delivery of the returned Activity.
-
- NOTE The function that is decorated with this decorator MUST return an
- Activity.
-
- :param func: The function to be decorated.
- """
-
- @functools.wraps(func)
- def decorator(self, *args, **kwargs):
- """
- Send an activity.
- By default, the Activity is sent to the Actor followers collection.
- To override this behavior it's possible to call the function like this:
- actor.follow(..., to=[], cc=[], bto=[], bcc=[])
-
- https://www.w3.org/TR/activitypub/#delivery
- """
-
- forgefed_graph = graph.Graph()
-
- # Create the activity from the Actor by executing the action (function)
- activity = func(self, *args, **kwargs)
-
- # Add publishing datetime
- # - use UTC
- # - remove microseconds, use HH:MM:SS only
- # - add timezone info. There is also .astimezone() but it seems to
- # return the wrong value when used with .utcnow(). Bug?
- # - convert to ISO 8601 format
- activity['published'] = format_datetime(datetime.datetime.utcnow())
-
- # By default we send the activity to the as:followers collection
- activity['to'] = [ self.followers_uri ]
-
- # Create the list of recipients
- recipients = []
-
- # TODO Check if it's possible to simplify this code by using rdflib.Graph
- for field in [ 'to', 'cc', 'bto', 'bcc' ]:
- if field in kwargs:
- activity[field] = kwargs[field]
-
- if field in activity:
- if isinstance(activity[field], str):
- recipients.append(activity[field])
- else:
- recipients.extend(activity[field])
-
- # Save a copy of the Activity in the database and add it to the Actor's
- # OUTBOX before sending it
- forgefed_graph.parse(data=json.dumps(activity), format='json-ld')
- forgefed_graph.commit()
- forgefed_graph.add_collection_item(self.outbox_uri, activity['id'])
-
- # Now we are ready to POST to the remote actors
-
- # Before sending, remove bto and bcc according to spec.
- # https://www.w3.org/TR/activitypub/#client-to-server-interactions
- activity.pop('bto', None)
- activity.pop('bcc', None)
-
- # Stop here if there are no recipients.
- # https://www.w3.org/TR/activitypub/#h-note-8
- if len(recipients) == 0:
- log.debug('No recipients. Activity will not be sent.')
- return
-
- # Make sure the local actor has a GPG key before POSTing anything. The
- # remote Actor will use the key for versifying the Activity.
- forgefed_graph.test_or_set_key(self.local_uri, self.publickey_uri)
-
- # Create a new Celery task for each recipient. Activities are POSTed
- # individually because if one request fails we don't want to resend
- # the same Activity to *all* the recipients.
- for recipient in recipients:
- log.debug('Scheduling new activity: id={} recipient={}'.format(
- activity['id'], recipient))
-
- tasks.activity.post.delay(
- activity = activity,
- recipient_uri = recipient,
- key_uri = self.publickey_uri,
- depth = settings.DELIVERY_DEPTH)
-
- forgefed_graph.disconnect()
-
- return decorator
- class ActivityStreamObject:
- """
- An ActivityStrem Object.
- """
-
- @property
- def uri(self):
- """
- The URI of this object.
- """
-
- remote_uri = self.remote_uri
- local_uri = self.local_uri
-
- if remote_uri: return remote_uri
- if local_uri: return local_uri
- return None
-
- @property
- def remote_uri(self):
- """
- The URI of the remote object if this object is only a local placeholder
- for a remote object.
- """
-
- g = graph.Graph()
- uri = g.value(rdflib.URIRef(self.local_uri), rdflib.OWL.sameAs)
- return str(uri) if uri else uri
-
- @property
- def is_remote(self):
- """
- Return True if this object is a local copy of a remote object. An example
- of such object would be a Ticket: a local copy is created such that users
- on the local instance can use the Pagure UI to interact with the remote
- Ticket.
- """
-
- return self.remote_uri != None
-
- @property
- def jsonld(self):
- return { '@context': activitypub.jsonld_context }
- class Actor(ActivityStreamObject):
- """
- An ActivityStream Actor.
- """
-
- def __repr__(self):
- raise Exception('Not implemented.')
-
- @property
- def inbox_uri(self):
- return self.local_uri + '/inbox'
-
- @property
- def outbox_uri(self):
- return self.local_uri + '/outbox'
-
- @property
- def followers_uri(self):
- return self.local_uri + '/followers'
-
- @property
- def following_uri(self):
- return self.local_uri + '/following'
-
- @property
- def publickey_uri(self):
- return self.local_uri + '/key.pub'
-
- @property
- def jsonld(self):
- return {
- **super().jsonld,
- 'id': self.local_uri,
- 'inbox': self.inbox_uri,
- 'outbox': self.outbox_uri,
- 'followers': self.followers_uri,
- 'following': self.following_uri,
- 'publicKey': self.publickey_uri }
-
- @action
- def accept(self, object_uri, *args, **kwargs):
- """
- Accept Activity.
-
- :param object_uri: URI of the ActivityPub object that was accepted.
- """
-
- return {
- '@context': activitypub.jsonld_context,
- 'id': self.outbox_uri + '/' + new_random_id(),
- 'type': 'Accept',
- 'actor': self.local_uri,
- 'object': object_uri,
- **kwargs }
-
- @action
- def create(self, object_uri, *args, **kwargs):
- """
- Create Activity.
-
- :param object_uri: URI of the ActivityPub object that was created.
- """
-
- return {
- '@context': activitypub.jsonld_context,
- 'id': self.outbox_uri + '/' + new_random_id(),
- 'type': 'Create',
- 'actor': self.local_uri,
- 'object': object_uri }
-
- @action
- def follow(self, object_uri, *args, **kwargs):
- """
- Follow Activity.
-
- :param object_uri: URI of the ActivityPub object to follow (an Actor).
- """
-
- return {
- '@context': activitypub.jsonld_context,
- 'id': self.outbox_uri + '/' + new_random_id(),
- 'type': 'Follow',
- 'actor': self.local_uri,
- 'object': object_uri }
-
- @action
- def offer(self, object, *args, **kwargs):
- """
- Offer Activity.
-
- :param object: Object to offer. Either a URI or a dictionary.
- """
-
- return {
- '@context': activitypub.jsonld_context,
- 'id': self.outbox_uri + '/' + new_random_id(),
- 'type': 'Offer',
- 'actor': self.local_uri,
- 'object': object }
-
- @action
- def update(self, object, *args, **kwargs):
- """
- Update Activity.
-
- :param object: The object that was updated.
- """
-
- return {
- '@context': activitypub.jsonld_context,
- 'id': self.outbox_uri + '/' + new_random_id(),
- 'type': 'Update',
- 'actor': self.local_uri,
- 'object': object }
- class Person(pagure.lib.model.User, Actor):
- """
- An ActivityStream Person.
- """
-
- def __repr__(self):
- return {
- 'class': type(self).__name__,
- 'type': 'Person',
- 'id': self.id,
- 'name': self.fullname,
- 'actor_uri': self.uri }
-
- @property
- def local_uri(self):
- return '{}/{}'.format(APP_URL, self.url_path)
-
- @property
- def jsonld(self):
- return {
- **super().jsonld,
- 'type': 'Person',
- 'name': self.fullname,
- 'preferredUsername': self.username }
-
- def handle_incoming_activity(self, activity):
- tasks.person.handle_incoming_activity.delay(self.id, activity)
- class Repository(pagure.lib.model.Project, Actor):
- """
- A ForgeFed Repository.
- """
-
- def __repr__(self):
- return {
- 'class': type(self).__name__,
- 'type': 'Repository',
- 'id': self.id,
- 'name': self.name,
- 'namespace': self.namespace,
- 'is_fork': self.is_fork,
- 'actor_uri': self.uri }
-
- @property
- def local_uri(self):
- return APP_URL + '/' + self.url_path + '.git'
-
- @property
- def jsonld(self):
- return {
- **super().jsonld,
- 'type': 'Repository',
- 'name': self.name,
- 'team': None }
-
- def handle_incoming_activity(self, activity):
- tasks.repository.handle_incoming_activity.delay(self.id, activity)
- class Projects(pagure.lib.model.Project, Actor):
- """
- A ForgeFed Project.
- """
-
- def __repr__(self):
- return {
- 'class': type(self).__name__,
- 'type': 'Project',
- 'id': self.id,
- 'name': self.name,
- 'namespace': self.namespace,
- 'actor_uri': self.uri }
-
- @property
- def local_uri(self):
- return APP_URL + '/' + self.url_path
-
- @property
- def jsonld(self):
- return {
- **super().jsonld,
- 'type': 'Project',
- 'name': self.name,
- 'preferredUsername': 'project/{}'.format(self.url_path) }
-
- def handle_incoming_activity(self, activity):
- tasks.project.handle_incoming_activity.delay(self.id, activity)
- class Ticket(pagure.lib.model.Issue, ActivityStreamObject):
- @property
- def local_uri(self):
- return '{}/{}/issue/{}'.format(APP_URL, self.project.url_path, self.id)
-
- @property
- def jsonld(self):
- return {
- **super().jsonld,
- 'id': self.local_uri,
- 'type': 'Ticket',
- 'context': '{}/{}'.format(APP_URL, self.project.url_path),
- 'attributedTo': '{}/{}'.format(APP_URL, self.user.url_path),
- 'summary': self.title,
- 'content': self.content,
- 'mediaType': 'text/plain',
- 'source': {
- 'content': self.content,
- 'mediaType': 'text/markdown; variant=CommonMark'
- },
- 'assignedTo': None,
- 'isResolved': False
- }
- class TicketComment(pagure.lib.model.IssueComment, ActivityStreamObject):
- @property
- def local_uri(self):
- return '{}/federation/ticket_comment/{}'.format(APP_URL, self.id)
-
- @property
- def jsonld(self):
- # Find the "context" of the comment (ie. the URI of the Ticket it belongs to)
- ticket = self.issue #copy.deepcopy(self.issue)
- ticket.__class__ = Ticket
- context = ticket.uri
-
- return {
- **super().jsonld,
- 'id': self.local_uri,
- 'type': 'Note',
- 'context': context,
- 'attributedTo': APP_URL + '/' + self.user.url_path,
- 'inReplyTo': None, # Pagure does not use nested comments
- 'mediaType': 'text/plain',
- 'content': self.comment,
- 'source': {
- 'mediaType': 'text/markdown; variant=Commonmark',
- 'content': self.comment
- },
- 'published': format_datetime(self.date_created)
- }
- class MRComment(pagure.lib.model.IssueComment, ActivityStreamObject):
- """
- Merge Request Comment.
- """
-
- @property
- def local_uri(self):
- return '{}/{}/issue/{}'.format(APP_URL, self.project.url_path, self.id)
-
- @property
- def jsonld(self):
- return {
- **super().jsonld,
- }
|