123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570 |
- """
- 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 copy
- import datetime
- import logging
- import random
- import re
- import requests
- import string
- from . import APP_URL
- from . import model
- from . import settings
- log = logging.getLogger(__name__)
- # List of HTTP headers used by ActivityPub.
- # https://www.w3.org/TR/activitypub/#server-to-server-interactions
- default_header = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'
- optional_header = 'application/activity+json'
- headers = [ default_header, optional_header ]
- # Headers to use when GETting/POSTing remote objects
- REQUEST_HEADERS = { 'Accept': default_header,
- 'Content-Type': default_header}
- # The JSON-LD context to use with ActivityPub activities.
- jsonld_context = [
- 'https://www.w3.org/ns/activitystreams',
- 'https://w3id.org/security/v1',
- 'https://forgefed.peers.community/ns' ]
- # Fetch an ActivityPub object
- def fetch(uri):
- response = requests.get(uri, headers=REQUEST_HEADERS)
- if response.status_code != 200:
- log.info('[{}] Error while retrieving object: {}'.format(response.status_code, uri))
- return None
- # The remote server is expected to serve a JSON-LD document.
- object = response.json()
- # Because JSON-LD can represent the same graph in several different
- # ways, we should normalize the JSONLD object before passing it to the
- # actor for processing. This simplifies working with the object.
- # Normalization could mean "flattening" or "compaction" of the JSONLD
- # document.
- # However, this step is left out for now and not implemented unless
- # needed because the ActivityStream specs already specifies that
- # objects should be served in compact form:
- # https://www.w3.org/TR/social-web-protocols/#content-representation
- #
- # object = response.json()
- # return normalize(object)
- return Document(object)
- # Cache a copy of the JSON-LD context such that we can work with activities
- # without sending HTTP requests all the time.
- cached_jsonld_context = []
- for context in jsonld_context:
- cached_jsonld_context.append(requests.get(context, headers=REQUEST_HEADERS).json())
- 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 new_activity_id(length=32):
- """
- Generate a random string suitable for using as part of an Activity ID.
- """
- symbols = string.ascii_lowercase + string.digits
- return ''.join([ random.choice(symbols) for i in range(length) ])
- class Document(dict):
- """
- A Document represents a JSON-LD "document". It's an extension of a Python
- dictionary with a couple of extra methods that facilitate the handling of
- ActivityPub objects.
- """
- # This is a type that can be used when matching a Document against another
- # Document (see match()). It's a special type that doesn't match any value;
- # it merely checks that the given key exists.
- # Usage example: pattern={ 'key': Document.AnyType() }
- class AnyType: pass
- def match(self, pattern):
- """
- Check if this Document matches another dictionary. If this Document
- contains all the keys in :pattern:, and they have the same value, then
- the function returns True. Otherwise return False.
- To only check for existence of a key, not its value, use the empty
- dictionary like this: pattern={"key": {}}
- :param pattern: The Document (or Python dictionary) to match this Document against.
- """
- for key, value in pattern.items():
- if key not in self:
- return False
- if isinstance(value, Document.AnyType):
- continue
- if isinstance(value, dict):
- self.node(key)
- if not self[key].match(value):
- return False
- elif isinstance(value, re.Pattern):
- if not isinstance(self[key], str):
- return False
- # Search if the given regex matches the value in the dictionary
- if value.search(self[key]) is None:
- return False
- else:
- if self[key] != value:
- return False
- return True
- def first(self, property):
- """
- Return value if it's a scalar, otherwise return the first element if it
- is an array.
- """
- if isinstance(self[property], list):
- return self[property][0]
- if isinstance(self[property], dict):
- return None
- return self[property]
- def node(self, property):
- """
- Return the value if it's an object. If it's a string, try to fetch a
- URI.
- """
- if isinstance(self[property], Document):
- return self[property]
- if isinstance(self[property], dict):
- self[property] = Document(self[property])
- return self[property]
- if isinstance(self[property], str):
- self[property] = Document(fetch(self[property]))
- return self[property]
- return None
- class Activity(Document):
- def from_dict(activity):
- """
- Return an Activity document given an Activity dictionary
- """
- document = Activity(type=activity['type'])
- document.update(**activity)
- return document
- def __init__(self, type='Activity', *args, **kwargs):
- super().__init__(*args, **kwargs)
- self.update(kwargs)
- self.update({
- '@context': jsonld_context,
- 'type': type
- })
- def get_receivers_addresses(self):
- """
- Helper function for extracting a list of all the recipients of an Activity.
- """
- recipients = []
- for field in [ 'to', 'cc', 'bto', 'bcc' ]:
- if field not in self:
- continue
- if isinstance(self[field], str):
- recipients.append(self[field])
- elif isinstance(self[field], list):
- recipients.extend(self[field])
- else:
- log.error('Field {} in Activity {} cannot be of type {}'.format(field, self['id'], type(self[field])))
- # Remove duplicates in the list of recipients, so that we will schedule
- # only one task for the same recipient Actor.
- # NOTE list(set()) might change the order of the recipients because set()
- # does not preserve the order. However this is not a problem because
- # for each recipient a separate task is scheduled.
- recipients = list(set(recipients))
- return recipients
- def get_forwarding_addresses(self):
- """
- Return only a list of addresses for INBOX forwarding.
- """
- recipients = self.get_receivers_addresses()
- return [
- recipient for recipient in recipients
- if recipient.startswith(APP_URL)
- ]
- def distribute(self):
- # Defer importing "tasks" to only when it's needed, here. Otherwise it
- # creates a circular dependency error during initialization.
- # "tasks" needs to import other modules that in turn need to import the
- # "activitupub" module.
- from . import tasks
- tasks.delivery.distribute.delay(self)
- class Collection(Document):
- def __init__(self):
- self.update({
- '@context': jsonld_context,
- 'type': 'Collection'
- })
- class CryptographicKey(Document):
- def __init__(self, key):
- self.update({
- '@context': jsonld_context,
- 'type': 'CryptographicKey',
- 'id': key.uri,
- 'owner': key.actor_uri,
- 'publicKeyPem': key.publickey_pem,
- 'created': None,
- 'expires': None,
- 'revoked': None,
- #'privateKeyPem': DO NOT DISPLAY PRIVATE KEY
- })
- class SshKey(Document):
- def __init__(self, key):
- # Break up the SSH key components
- # An OpenSSH is formatted like <algorithm> <key> <comment>
- components = key.public_ssh_key.split(' ', 2)
- algorithm = components[0].strip()
- string = components[1].strip()
- comment = components[2].strip() if len(components) > 2 else ''
- self.update({
- '@context': jsonld_context,
- 'type': 'SshKey',
- 'id': key.uri,
- 'owner': key.user.uri,
- 'sshKeyType': algorithm,
- 'content': key.public_ssh_key,
- 'created': None,
- 'expires': None,
- 'revoked': None,
- })
- class OrderedCollection(Document):
- def __init__(self, collection_uri):
- self.update({
- '@context': jsonld_context,
- 'type': 'OrderedCollection',
- 'id': collection_uri,
- 'current': collection_uri + '/0',
- 'first': collection_uri + '/0',
- 'last': collection_uri + '/0',
- 'totalItems': 0
- })
- class OrderedCollectionPage(Document):
- def __init__(self, collection_uri, page_uri, items):
- self.update({
- '@context': jsonld_context,
- 'type': 'OrderedCollectionPage',
- 'id': page_uri,
- 'partOf': collection_uri,
- 'orderedItems': items
- })
- class Person(Document):
- def __init__(self, actor):
- self.update({
- '@context': jsonld_context,
- 'type': 'Person',
- 'id': actor.uri,
- 'inbox': actor.uri + '/inbox',
- 'outbox': actor.uri + '/outbox',
- 'followers': actor.uri + '/followers',
- 'following': actor.uri + '/following',
- 'publicKey': actor.uri + '/key.pub',
- 'name': actor.fullname,
- 'preferredUsername': actor.username,
- 'sshKey': [ key.uri for key in actor.sshkeys ],
- 'roles': [ role.uri for role in actor.roles ]
- })
- class Group(Document):
- def __init__(self, group):
- self.update({
- '@context': jsonld_context,
- 'type': 'Group',
- 'id': group.uri,
- 'name': group.display_name,
- 'preferredUsername': group.group_name,
- 'summary': group.description,
- 'roles': [ role.uri for role in group.roles ]
- })
- class Role(Document):
- def __init__(self, project, role):
- self.update({
- '@context': jsonld_context,
- 'type': 'Role',
- 'id': '{}/role/{}'.format(project.uri, role.access),
- 'name': role.access,
- 'context': project.uri
- })
- class Project(Document):
- def __init__(self, project):
- self.update({
- '@context': jsonld_context,
- 'type': 'Project',
- 'id': project.uri,
- 'name': project.fullname,
- 'repository': [ project.uri + '.git' ],
- 'tickettracker': [ project.uri + '/issues' ]
- })
- class Tag(Document):
- def __init__(self, tag):
- self.update({
- '@context': jsonld_context,
- 'type': 'Tag',
- 'id': tag.uri,
- 'name': tag.tag,
- 'summary': tag.tag_description
- })
- class Repository(Document):
- def __init__(self, project, repository, git_repository):
- self.update({
- '@context': jsonld_context,
- 'type': 'Repository',
- 'id': repository.uri,
- 'inbox': repository.uri + '/inbox',
- 'outbox': repository.uri + '/outbox',
- 'followers': repository.uri + '/followers',
- 'following': repository.uri + '/following',
- 'publicKey': repository.uri + '/key.pub',
- 'name': repository.fullname,
- 'preferredUsername': repository.fullname,
- 'project': project.uri,
- 'refs': repository.uri + '/refs'
- })
- class Branch(Document):
- def __init__(self, project, repository, branch_name):
- self.update({
- '@context': jsonld_context,
- 'type': 'Branch',
- 'id': '{}/tree/{}'.format(project.uri, branch_name),
- 'context': repository.uri
- })
- class Commit(Document):
- def __init__(self, project, repository, commit):
- """
- :param commit: The commit
- :type commit: pygit2.Commit
- """
- self.update({
- '@context': jsonld_context,
- 'type': 'Commit',
- 'id': '{}/c/{}'.format(project.uri, commit.id.hex),
- 'context': repository.uri,
- 'attributedTo': '',
- 'committedBy': '',
- 'hash': commit.id.hex,
- 'summary': commit.message.split('\n', 1)[0],
- 'description': commit.message,
- 'created': '',
- 'committed': commit.commit_time
- })
- class Refs(Document):
- def __init__(self, repository, git_repository):
- # Get all the tags
- regex = re.compile('^refs/tags/')
- tags = [ ref
- for ref in git_repository.references
- if regex.match(ref) ]
- self.update({
- '@context': jsonld_context,
- 'type': 'Refs',
- 'id': '{}/refs'.format(repository.uri),
- 'context': repository.uri,
- 'heads': [],
- 'remotes': [],
- 'tags': [ '{}/{}'.format(repository.uri, ref) for ref in tags ]
- })
- class TagRef(Document):
- def __init__(self, repository, ref):
- self.update({
- '@context': jsonld_context,
- 'type': 'TagRef',
- 'id': '{}/{}'.format(repository.uri, ref),
- 'name': ref,
- 'context': repository.uri
- })
- class TicketTracker(Document):
- def __init__(self, tracker, project):
- self.update({
- '@context': jsonld_context,
- 'type': 'TicketTracker',
- 'id': tracker.uri,
- 'inbox': tracker.uri + '/inbox',
- 'outbox': tracker.uri + '/outbox',
- 'followers': tracker.uri + '/followers',
- 'following': tracker.uri + '/following',
- 'publicKey': tracker.uri + '/key.pub',
- 'name': tracker.fullname,
- 'preferredUsername': tracker.fullname,
- 'project': project.uri
- })
- class Ticket(Document):
- def __init__(self, ticket, tickettracker):
- self.update({
- '@context': jsonld_context,
- 'type': 'Ticket',
- 'id': ticket.uri,
- 'context': tickettracker.uri,
- 'attributedTo': ticket.user.uri,
- 'summary': ticket.title,
- 'content': ticket.content,
- 'mediaType': "text/plain",
- 'source': {
- 'content': ticket.content,
- 'mediaType': 'text/markdown; variant=CommonMark'
- },
- 'assignedTo': [ ticket.assignee.uri ] if ticket.assignee else [],
- 'isResolved': ticket.status.upper() == 'CLOSED',
- 'depends': [ parent.uri for parent in ticket.parents ],
- 'tags': sorted([ tag.uri for tag in ticket.tags ]),
- 'milestones': [ ticket.milestone ] if ticket.milestone else []
- })
- class TicketComment(Document):
- def __init__(self, comment):
- self.update({
- '@context': jsonld_context,
- 'id': comment.uri,
- 'type': 'Note',
- 'context': comment.issue.uri,
- 'attributedTo': comment.user.uri,
- 'inReplyTo': None, # Pagure does not use nested comments
- 'mediaType': 'text/plain',
- 'content': comment.comment,
- 'source': {
- 'mediaType': 'text/markdown; variant=Commonmark',
- 'content': comment.comment
- },
- 'published': format_datetime(comment.date_created)
- })
- class MergeRequest(Document):
- def __init__(self, mergerequest):
- """
- :param mergerequest: The Pagure PullRequest object
- """
- # PullRequest in the Pagure model has a foreign-key link to Project class.
- # We need to "cast" Project to Repository in order to get the correct
- # federation URI.
- # Additionally a PullRequest in Pagure might not have a "project_from"
- # link, but it should have a "remote_git" if it's a "remote PR".
- #upstream = copy.deepcopy(mergerequest.project)
- upstream = mergerequest.project
- upstream.__class__ = model.Repository
- upstream = upstream.uri
- #downstream = copy.deepcopy(mergerequest.project_from)
- downstream = mergerequest.project_from
- if downstream:
- downstream.__class__ = model.Repository
- downstream = downstream.uri
- elif mergerequest.remote_git:
- downstream = mergerequest.remote_git
- self.update({
- '@context': jsonld_context,
- 'type': 'MergeRequest',
- 'id': mergerequest.uri,
- 'attributedTo': mergerequest.user.uri,
- 'summary': mergerequest.title,
- 'content': mergerequest.initial_comment,
- 'downstream': {
- 'repository': downstream,
- 'branch': mergerequest.branch_from
- },
- 'upstream': {
- 'repository': upstream,
- 'branch': mergerequest.branch
- },
- 'commit_start': mergerequest.commit_start,
- 'commit_stop': mergerequest.commit_stop
- })
- class MergeRequestComment(Document):
- def __init__(self, comment):
- self.update({
- '@context': jsonld_context,
- 'id': comment.uri,
- 'type': 'Note',
- 'context': comment.pull_request.uri,
- 'attributedTo': comment.user.uri,
- 'inReplyTo': None, # Pagure does not use nested comments
- 'mediaType': 'text/plain',
- 'content': comment.comment,
- 'source': {
- 'mediaType': 'text/markdown; variant=Commonmark',
- 'content': comment.comment
- },
- 'published': format_datetime(comment.date_created)
- })
|