activitypub.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570
  1. """
  2. ForgeFed plugin for Pagure.
  3. Copyright (C) 2020-2021 zPlus <zplus@peers.community>
  4. This program is free software; you can redistribute it and/or modify
  5. it under the terms of the GNU General Public License as published by
  6. the Free Software Foundation; either version 2 of the License, or
  7. (at your option) any later version.
  8. This program is distributed in the hope that it will be useful,
  9. but WITHOUT ANY WARRANTY; without even the implied warranty of
  10. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  11. GNU General Public License for more details.
  12. You should have received a copy of the GNU General Public License along
  13. with this program; if not, see <https://www.gnu.org/licenses/>.
  14. SPDX-FileCopyrightText: 2020-2021 zPlus <zplus@peers.community>
  15. SPDX-License-Identifier: GPL-2.0-only
  16. """
  17. import copy
  18. import datetime
  19. import logging
  20. import random
  21. import re
  22. import requests
  23. import string
  24. from . import APP_URL
  25. from . import model
  26. from . import settings
  27. log = logging.getLogger(__name__)
  28. # List of HTTP headers used by ActivityPub.
  29. # https://www.w3.org/TR/activitypub/#server-to-server-interactions
  30. default_header = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'
  31. optional_header = 'application/activity+json'
  32. headers = [ default_header, optional_header ]
  33. # Headers to use when GETting/POSTing remote objects
  34. REQUEST_HEADERS = { 'Accept': default_header,
  35. 'Content-Type': default_header}
  36. # The JSON-LD context to use with ActivityPub activities.
  37. jsonld_context = [
  38. 'https://www.w3.org/ns/activitystreams',
  39. 'https://w3id.org/security/v1',
  40. 'https://forgefed.peers.community/ns' ]
  41. # Fetch an ActivityPub object
  42. def fetch(uri):
  43. response = requests.get(uri, headers=REQUEST_HEADERS)
  44. if response.status_code != 200:
  45. log.info('[{}] Error while retrieving object: {}'.format(response.status_code, uri))
  46. return None
  47. # The remote server is expected to serve a JSON-LD document.
  48. object = response.json()
  49. # Because JSON-LD can represent the same graph in several different
  50. # ways, we should normalize the JSONLD object before passing it to the
  51. # actor for processing. This simplifies working with the object.
  52. # Normalization could mean "flattening" or "compaction" of the JSONLD
  53. # document.
  54. # However, this step is left out for now and not implemented unless
  55. # needed because the ActivityStream specs already specifies that
  56. # objects should be served in compact form:
  57. # https://www.w3.org/TR/social-web-protocols/#content-representation
  58. #
  59. # object = response.json()
  60. # return normalize(object)
  61. return Document(object)
  62. # Cache a copy of the JSON-LD context such that we can work with activities
  63. # without sending HTTP requests all the time.
  64. cached_jsonld_context = []
  65. for context in jsonld_context:
  66. cached_jsonld_context.append(requests.get(context, headers=REQUEST_HEADERS).json())
  67. def format_datetime(dt):
  68. """
  69. This function is used to format a datetime object into a string that is
  70. suitable for publishing in Activities.
  71. """
  72. return dt.replace(microsecond=0) \
  73. .replace(tzinfo=datetime.timezone.utc) \
  74. .isoformat()
  75. def new_activity_id(length=32):
  76. """
  77. Generate a random string suitable for using as part of an Activity ID.
  78. """
  79. symbols = string.ascii_lowercase + string.digits
  80. return ''.join([ random.choice(symbols) for i in range(length) ])
  81. class Document(dict):
  82. """
  83. A Document represents a JSON-LD "document". It's an extension of a Python
  84. dictionary with a couple of extra methods that facilitate the handling of
  85. ActivityPub objects.
  86. """
  87. # This is a type that can be used when matching a Document against another
  88. # Document (see match()). It's a special type that doesn't match any value;
  89. # it merely checks that the given key exists.
  90. # Usage example: pattern={ 'key': Document.AnyType() }
  91. class AnyType: pass
  92. def match(self, pattern):
  93. """
  94. Check if this Document matches another dictionary. If this Document
  95. contains all the keys in :pattern:, and they have the same value, then
  96. the function returns True. Otherwise return False.
  97. To only check for existence of a key, not its value, use the empty
  98. dictionary like this: pattern={"key": {}}
  99. :param pattern: The Document (or Python dictionary) to match this Document against.
  100. """
  101. for key, value in pattern.items():
  102. if key not in self:
  103. return False
  104. if isinstance(value, Document.AnyType):
  105. continue
  106. if isinstance(value, dict):
  107. self.node(key)
  108. if not self[key].match(value):
  109. return False
  110. elif isinstance(value, re.Pattern):
  111. if not isinstance(self[key], str):
  112. return False
  113. # Search if the given regex matches the value in the dictionary
  114. if value.search(self[key]) is None:
  115. return False
  116. else:
  117. if self[key] != value:
  118. return False
  119. return True
  120. def first(self, property):
  121. """
  122. Return value if it's a scalar, otherwise return the first element if it
  123. is an array.
  124. """
  125. if isinstance(self[property], list):
  126. return self[property][0]
  127. if isinstance(self[property], dict):
  128. return None
  129. return self[property]
  130. def node(self, property):
  131. """
  132. Return the value if it's an object. If it's a string, try to fetch a
  133. URI.
  134. """
  135. if isinstance(self[property], Document):
  136. return self[property]
  137. if isinstance(self[property], dict):
  138. self[property] = Document(self[property])
  139. return self[property]
  140. if isinstance(self[property], str):
  141. self[property] = Document(fetch(self[property]))
  142. return self[property]
  143. return None
  144. class Activity(Document):
  145. def from_dict(activity):
  146. """
  147. Return an Activity document given an Activity dictionary
  148. """
  149. document = Activity(type=activity['type'])
  150. document.update(**activity)
  151. return document
  152. def __init__(self, type='Activity', *args, **kwargs):
  153. super().__init__(*args, **kwargs)
  154. self.update(kwargs)
  155. self.update({
  156. '@context': jsonld_context,
  157. 'type': type
  158. })
  159. def get_receivers_addresses(self):
  160. """
  161. Helper function for extracting a list of all the recipients of an Activity.
  162. """
  163. recipients = []
  164. for field in [ 'to', 'cc', 'bto', 'bcc' ]:
  165. if field not in self:
  166. continue
  167. if isinstance(self[field], str):
  168. recipients.append(self[field])
  169. elif isinstance(self[field], list):
  170. recipients.extend(self[field])
  171. else:
  172. log.error('Field {} in Activity {} cannot be of type {}'.format(field, self['id'], type(self[field])))
  173. # Remove duplicates in the list of recipients, so that we will schedule
  174. # only one task for the same recipient Actor.
  175. # NOTE list(set()) might change the order of the recipients because set()
  176. # does not preserve the order. However this is not a problem because
  177. # for each recipient a separate task is scheduled.
  178. recipients = list(set(recipients))
  179. return recipients
  180. def get_forwarding_addresses(self):
  181. """
  182. Return only a list of addresses for INBOX forwarding.
  183. """
  184. recipients = self.get_receivers_addresses()
  185. return [
  186. recipient for recipient in recipients
  187. if recipient.startswith(APP_URL)
  188. ]
  189. def distribute(self):
  190. # Defer importing "tasks" to only when it's needed, here. Otherwise it
  191. # creates a circular dependency error during initialization.
  192. # "tasks" needs to import other modules that in turn need to import the
  193. # "activitupub" module.
  194. from . import tasks
  195. tasks.delivery.distribute.delay(self)
  196. class Collection(Document):
  197. def __init__(self):
  198. self.update({
  199. '@context': jsonld_context,
  200. 'type': 'Collection'
  201. })
  202. class CryptographicKey(Document):
  203. def __init__(self, key):
  204. self.update({
  205. '@context': jsonld_context,
  206. 'type': 'CryptographicKey',
  207. 'id': key.uri,
  208. 'owner': key.actor_uri,
  209. 'publicKeyPem': key.publickey_pem,
  210. 'created': None,
  211. 'expires': None,
  212. 'revoked': None,
  213. #'privateKeyPem': DO NOT DISPLAY PRIVATE KEY
  214. })
  215. class SshKey(Document):
  216. def __init__(self, key):
  217. # Break up the SSH key components
  218. # An OpenSSH is formatted like <algorithm> <key> <comment>
  219. components = key.public_ssh_key.split(' ', 2)
  220. algorithm = components[0].strip()
  221. string = components[1].strip()
  222. comment = components[2].strip() if len(components) > 2 else ''
  223. self.update({
  224. '@context': jsonld_context,
  225. 'type': 'SshKey',
  226. 'id': key.uri,
  227. 'owner': key.user.uri,
  228. 'sshKeyType': algorithm,
  229. 'content': key.public_ssh_key,
  230. 'created': None,
  231. 'expires': None,
  232. 'revoked': None,
  233. })
  234. class OrderedCollection(Document):
  235. def __init__(self, collection_uri):
  236. self.update({
  237. '@context': jsonld_context,
  238. 'type': 'OrderedCollection',
  239. 'id': collection_uri,
  240. 'current': collection_uri + '/0',
  241. 'first': collection_uri + '/0',
  242. 'last': collection_uri + '/0',
  243. 'totalItems': 0
  244. })
  245. class OrderedCollectionPage(Document):
  246. def __init__(self, collection_uri, page_uri, items):
  247. self.update({
  248. '@context': jsonld_context,
  249. 'type': 'OrderedCollectionPage',
  250. 'id': page_uri,
  251. 'partOf': collection_uri,
  252. 'orderedItems': items
  253. })
  254. class Person(Document):
  255. def __init__(self, actor):
  256. self.update({
  257. '@context': jsonld_context,
  258. 'type': 'Person',
  259. 'id': actor.uri,
  260. 'inbox': actor.uri + '/inbox',
  261. 'outbox': actor.uri + '/outbox',
  262. 'followers': actor.uri + '/followers',
  263. 'following': actor.uri + '/following',
  264. 'publicKey': actor.uri + '/key.pub',
  265. 'name': actor.fullname,
  266. 'preferredUsername': actor.username,
  267. 'sshKey': [ key.uri for key in actor.sshkeys ],
  268. 'roles': [ role.uri for role in actor.roles ]
  269. })
  270. class Group(Document):
  271. def __init__(self, group):
  272. self.update({
  273. '@context': jsonld_context,
  274. 'type': 'Group',
  275. 'id': group.uri,
  276. 'name': group.display_name,
  277. 'preferredUsername': group.group_name,
  278. 'summary': group.description,
  279. 'roles': [ role.uri for role in group.roles ]
  280. })
  281. class Role(Document):
  282. def __init__(self, project, role):
  283. self.update({
  284. '@context': jsonld_context,
  285. 'type': 'Role',
  286. 'id': '{}/role/{}'.format(project.uri, role.access),
  287. 'name': role.access,
  288. 'context': project.uri
  289. })
  290. class Project(Document):
  291. def __init__(self, project):
  292. self.update({
  293. '@context': jsonld_context,
  294. 'type': 'Project',
  295. 'id': project.uri,
  296. 'name': project.fullname,
  297. 'repository': [ project.uri + '.git' ],
  298. 'tickettracker': [ project.uri + '/issues' ]
  299. })
  300. class Tag(Document):
  301. def __init__(self, tag):
  302. self.update({
  303. '@context': jsonld_context,
  304. 'type': 'Tag',
  305. 'id': tag.uri,
  306. 'name': tag.tag,
  307. 'summary': tag.tag_description
  308. })
  309. class Repository(Document):
  310. def __init__(self, project, repository, git_repository):
  311. self.update({
  312. '@context': jsonld_context,
  313. 'type': 'Repository',
  314. 'id': repository.uri,
  315. 'inbox': repository.uri + '/inbox',
  316. 'outbox': repository.uri + '/outbox',
  317. 'followers': repository.uri + '/followers',
  318. 'following': repository.uri + '/following',
  319. 'publicKey': repository.uri + '/key.pub',
  320. 'name': repository.fullname,
  321. 'preferredUsername': repository.fullname,
  322. 'project': project.uri,
  323. 'refs': repository.uri + '/refs'
  324. })
  325. class Branch(Document):
  326. def __init__(self, project, repository, branch_name):
  327. self.update({
  328. '@context': jsonld_context,
  329. 'type': 'Branch',
  330. 'id': '{}/tree/{}'.format(project.uri, branch_name),
  331. 'context': repository.uri
  332. })
  333. class Commit(Document):
  334. def __init__(self, project, repository, commit):
  335. """
  336. :param commit: The commit
  337. :type commit: pygit2.Commit
  338. """
  339. self.update({
  340. '@context': jsonld_context,
  341. 'type': 'Commit',
  342. 'id': '{}/c/{}'.format(project.uri, commit.id.hex),
  343. 'context': repository.uri,
  344. 'attributedTo': '',
  345. 'committedBy': '',
  346. 'hash': commit.id.hex,
  347. 'summary': commit.message.split('\n', 1)[0],
  348. 'description': commit.message,
  349. 'created': '',
  350. 'committed': commit.commit_time
  351. })
  352. class Refs(Document):
  353. def __init__(self, repository, git_repository):
  354. # Get all the tags
  355. regex = re.compile('^refs/tags/')
  356. tags = [ ref
  357. for ref in git_repository.references
  358. if regex.match(ref) ]
  359. self.update({
  360. '@context': jsonld_context,
  361. 'type': 'Refs',
  362. 'id': '{}/refs'.format(repository.uri),
  363. 'context': repository.uri,
  364. 'heads': [],
  365. 'remotes': [],
  366. 'tags': [ '{}/{}'.format(repository.uri, ref) for ref in tags ]
  367. })
  368. class TagRef(Document):
  369. def __init__(self, repository, ref):
  370. self.update({
  371. '@context': jsonld_context,
  372. 'type': 'TagRef',
  373. 'id': '{}/{}'.format(repository.uri, ref),
  374. 'name': ref,
  375. 'context': repository.uri
  376. })
  377. class TicketTracker(Document):
  378. def __init__(self, tracker, project):
  379. self.update({
  380. '@context': jsonld_context,
  381. 'type': 'TicketTracker',
  382. 'id': tracker.uri,
  383. 'inbox': tracker.uri + '/inbox',
  384. 'outbox': tracker.uri + '/outbox',
  385. 'followers': tracker.uri + '/followers',
  386. 'following': tracker.uri + '/following',
  387. 'publicKey': tracker.uri + '/key.pub',
  388. 'name': tracker.fullname,
  389. 'preferredUsername': tracker.fullname,
  390. 'project': project.uri
  391. })
  392. class Ticket(Document):
  393. def __init__(self, ticket, tickettracker):
  394. self.update({
  395. '@context': jsonld_context,
  396. 'type': 'Ticket',
  397. 'id': ticket.uri,
  398. 'context': tickettracker.uri,
  399. 'attributedTo': ticket.user.uri,
  400. 'summary': ticket.title,
  401. 'content': ticket.content,
  402. 'mediaType': "text/plain",
  403. 'source': {
  404. 'content': ticket.content,
  405. 'mediaType': 'text/markdown; variant=CommonMark'
  406. },
  407. 'assignedTo': [ ticket.assignee.uri ] if ticket.assignee else [],
  408. 'isResolved': ticket.status.upper() == 'CLOSED',
  409. 'depends': [ parent.uri for parent in ticket.parents ],
  410. 'tags': sorted([ tag.uri for tag in ticket.tags ]),
  411. 'milestones': [ ticket.milestone ] if ticket.milestone else []
  412. })
  413. class TicketComment(Document):
  414. def __init__(self, comment):
  415. self.update({
  416. '@context': jsonld_context,
  417. 'id': comment.uri,
  418. 'type': 'Note',
  419. 'context': comment.issue.uri,
  420. 'attributedTo': comment.user.uri,
  421. 'inReplyTo': None, # Pagure does not use nested comments
  422. 'mediaType': 'text/plain',
  423. 'content': comment.comment,
  424. 'source': {
  425. 'mediaType': 'text/markdown; variant=Commonmark',
  426. 'content': comment.comment
  427. },
  428. 'published': format_datetime(comment.date_created)
  429. })
  430. class MergeRequest(Document):
  431. def __init__(self, mergerequest):
  432. """
  433. :param mergerequest: The Pagure PullRequest object
  434. """
  435. # PullRequest in the Pagure model has a foreign-key link to Project class.
  436. # We need to "cast" Project to Repository in order to get the correct
  437. # federation URI.
  438. # Additionally a PullRequest in Pagure might not have a "project_from"
  439. # link, but it should have a "remote_git" if it's a "remote PR".
  440. #upstream = copy.deepcopy(mergerequest.project)
  441. upstream = mergerequest.project
  442. upstream.__class__ = model.Repository
  443. upstream = upstream.uri
  444. #downstream = copy.deepcopy(mergerequest.project_from)
  445. downstream = mergerequest.project_from
  446. if downstream:
  447. downstream.__class__ = model.Repository
  448. downstream = downstream.uri
  449. elif mergerequest.remote_git:
  450. downstream = mergerequest.remote_git
  451. self.update({
  452. '@context': jsonld_context,
  453. 'type': 'MergeRequest',
  454. 'id': mergerequest.uri,
  455. 'attributedTo': mergerequest.user.uri,
  456. 'summary': mergerequest.title,
  457. 'content': mergerequest.initial_comment,
  458. 'downstream': {
  459. 'repository': downstream,
  460. 'branch': mergerequest.branch_from
  461. },
  462. 'upstream': {
  463. 'repository': upstream,
  464. 'branch': mergerequest.branch
  465. },
  466. 'commit_start': mergerequest.commit_start,
  467. 'commit_stop': mergerequest.commit_stop
  468. })
  469. class MergeRequestComment(Document):
  470. def __init__(self, comment):
  471. self.update({
  472. '@context': jsonld_context,
  473. 'id': comment.uri,
  474. 'type': 'Note',
  475. 'context': comment.pull_request.uri,
  476. 'attributedTo': comment.user.uri,
  477. 'inReplyTo': None, # Pagure does not use nested comments
  478. 'mediaType': 'text/plain',
  479. 'content': comment.comment,
  480. 'source': {
  481. 'mediaType': 'text/markdown; variant=Commonmark',
  482. 'content': comment.comment
  483. },
  484. 'published': format_datetime(comment.date_created)
  485. })