model.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687
  1. # -*- coding: utf-8 -*-
  2. """
  3. (c) 2020 - Copyright ...
  4. Authors:
  5. zPlus <zplus@peers.community>
  6. Notes:
  7. Useful documentation for SQLAlchemy ORM:
  8. https://docs.sqlalchemy.org/en/13/orm/tutorial.html
  9. https://docs.sqlalchemy.org/en/13/orm/basic_relationships.html
  10. """
  11. import copy
  12. import datetime
  13. import functools
  14. import logging
  15. import json
  16. import pagure.config
  17. import pagure.lib.model
  18. import pagure.lib.query
  19. import random
  20. import rdflib
  21. import string
  22. import urllib
  23. from . import activitypub
  24. from . import graph
  25. from . import settings
  26. from . import tasks
  27. # The app URL defined in the Pagure configuration, eg. "https://example.org/"
  28. # We need this for generating IDs.
  29. APP_URL = pagure.config.config['APP_URL'].rstrip('/')
  30. assert APP_URL and len(APP_URL) > 0, 'APP_URL missing from Pagure configuration.'
  31. log = logging.getLogger(__name__)
  32. def new_random_id(length=32):
  33. """
  34. Generate a random string to use as an Activity ID when a new one is
  35. created.
  36. """
  37. symbols = string.ascii_lowercase + string.digits
  38. return ''.join([ random.choice(symbols) for i in range(length) ])
  39. def format_datetime(dt):
  40. """
  41. This function is used to format a datetime object into a string that is
  42. suitable for publishing in Activities.
  43. """
  44. return dt.replace(microsecond=0) \
  45. .replace(tzinfo=datetime.timezone.utc) \
  46. .isoformat()
  47. def from_uri(pagure_db, uri):
  48. """
  49. In ActivityPub, objects' ID are URI and occasionally we need to retrieve an
  50. object from the (pagure) database given only its URI. If pagure were using
  51. a graph as a database, this would be trivial (just query "DESCRIBE <uri>").
  52. Also, the plugin does not mirror the entire pagure database into a graph;
  53. objects' documents (the JSON-LD that is returned when GETting) are created
  54. "on the fly" for every request. We could add the pagure ID to the JSON-LD
  55. object, but it would be very inelegant and hacky. We could add URIs to the
  56. pagure database, but it would be very brittle and would also require to
  57. change the pagure database schema. So we need a way, given only a URI, to
  58. reverse it and get the object in the database. This function does that.
  59. NOTES: This function is for *local* users only. It return an SQLAlchemy
  60. object given an ActivityPub URI.
  61. :param pagure_db: A session context of the pagure database.
  62. :param uri: The URI to reverse.
  63. """
  64. log.debug('from_uri: {}'.format(uri))
  65. # Fetch the JSON-LD document of the object.
  66. # Remember: we do not store the pagure objects in the graph, so we must
  67. # HTTP-GET the URI.
  68. object = activitypub.fetch(uri)
  69. uri = urllib.parse.urlsplit(uri)
  70. if object['type'] == 'Person':
  71. return pagure_db.query(Person) \
  72. .filter(Person.user == object['preferredUsername']) \
  73. .one_or_none()
  74. if object['type'] == 'Project':
  75. components = uri.path.lstrip('/').split('/')
  76. repo_username = None
  77. repo_namespace = None
  78. repo_name = None
  79. if len(components) == 1:
  80. repo_name = components[0]
  81. if len(components) == 2:
  82. repo_namespace = components[0]
  83. repo_name = components[1]
  84. if len(components) == 3:
  85. repo_username = components[1]
  86. repo_name = components[2]
  87. if len(components) == 4:
  88. repo_username = components[1]
  89. repo_namespace = components[2]
  90. repo_name = components[3]
  91. project = pagure_db.query(Projects).filter(Projects.name == repo_name)
  92. if repo_username:
  93. project = project.filter(Projects.is_fork == True) \
  94. .filter(Projects.user.has(Person.user == repo_username))
  95. else:
  96. project = project.filter(Projects.is_fork == False)
  97. if repo_namespace:
  98. project = project.filter(Projects.namespace == repo_namespace)
  99. return project.one_or_none()
  100. if object['type'] == 'Ticket':
  101. # Ticket "context" contains a link to the Project URI
  102. project = from_uri(pagure_db, object['context'])
  103. # Ticket ID from the URI
  104. ticket_id = uri.path.rsplit('/', 1)[-1]
  105. return pagure_db.query(Ticket) \
  106. .filter(Ticket.id == ticket_id,
  107. Ticket.project_id == project.id) \
  108. .one_or_none()
  109. if object['type'] == 'Note':
  110. if uri.path.startswith('/federation/ticket_comment/'):
  111. comment_id = uri.path.rsplit('/', 1)[-1]
  112. return pagure_db.query(TicketComment) \
  113. .filter(TicketComment.id == comment_id) \
  114. .one_or_none()
  115. # No object found
  116. return None
  117. def from_local_uri(pagure_db, uri):
  118. # Check if the URI is an actual local object
  119. if not uri.startswith(APP_URL):
  120. return None
  121. return from_uri(pagure_db, uri)
  122. def from_remote_uri(pagure_db, forgefed_graph, uri):
  123. # Check if the URI is an actual remote object
  124. if uri.startswith(APP_URL):
  125. return None
  126. # Get the URI of the local object that maps the remote object
  127. local_uri = forgefed_graph.value(
  128. predicate = rdflib.OWL.sameAs,
  129. object = rdflib.URIRef(uri))
  130. if not local_uri:
  131. return None
  132. # Cast type URIRef() to string
  133. local_uri = str(local_uri)
  134. return from_uri(pagure_db, local_uri)
  135. def test_or_set_remote_actor(pagure_db, forgefed_graph, uri):
  136. """
  137. We want to use the existing pagure UI to collaborate with remote users, for
  138. example to hold a conversation on a Ticket. Problem is, the pagure database
  139. model is built around *local* users that have IDs and relations with other
  140. schemas. So, we create a local mock user that represents a remote one, and
  141. call it user@domain.
  142. :param pagure_db: A session to the pagure database.
  143. :param forgefed_graph: A session to the forgefed graph.
  144. :param uri: The URI of the remote actor.
  145. """
  146. # Fetch JSONLD of the remote actor
  147. actor = activitypub.fetch(uri)
  148. webfinger = '{}@{}'.format(actor['preferredUsername'], urllib.parse.urlparse(uri).netloc)
  149. # This will create a new user in the Pagure database if it doesn't exist
  150. user = pagure.lib.query.set_up_user(
  151. session = pagure_db,
  152. username = webfinger.replace('/', '+'),
  153. fullname = actor['name'],
  154. default_email = webfinger)
  155. # Return a Person object instead of the pagure User class
  156. person = pagure_db.query(Person) \
  157. .filter(Person.id == user.id) \
  158. .one_or_none()
  159. # Set a owl:sameAs link in the forgefed graph, so that we know that this
  160. # person is only used to represent a remote user.
  161. forgefed_graph.set((rdflib.URIRef(person.local_uri),
  162. rdflib.OWL.sameAs,
  163. rdflib.URIRef(uri)))
  164. return person
  165. def test_or_set_remote_comment(pagure_db, forgefed_graph, uri):
  166. """
  167. This is the same as test_or_set_remote_actor() but for comments.
  168. :param pagure_db: A session to the pagure database.
  169. :param forgefed_graph: A session to the forgefed graph.
  170. :param uri: The URI of the remote Note.
  171. """
  172. # If the URI of the comment is a URI to the local instance, we don't
  173. # need to do anything because the pagure database already contains the
  174. # comment.
  175. if uri.startswith(APP_URL):
  176. return
  177. # Fetch JSONLD of the remote Note
  178. note = activitypub.fetch(uri)
  179. # Check if we already have a local object for this remote comment
  180. if (None, rdflib.OWL.sameAs, rdflib.URIRef(uri)) in forgefed_graph:
  181. log.debug('The note {} is already stored in the database. Will not create a new one.'.format(uri))
  182. return
  183. # Otherwise we create a local object in the pagure database for the remote Note...
  184. # If the "context" of the Note (which can be a Ticker or a MergeRequest) is
  185. # a local URL, it means somebody has created a Note for an object in our
  186. # database. This is the case for example when a remote user is contributing
  187. # a comment to a local project.
  188. if note['context'].startswith(APP_URL):
  189. # Get the database object
  190. context = from_local_uri(pagure_db, note['context'])
  191. # This is a new Note for a local Ticket
  192. if isinstance(context, Ticket):
  193. author = test_or_set_remote_actor(pagure_db, forgefed_graph, note['attributedTo'])
  194. # Create the new comment to the ticket
  195. pagure.lib.query.add_issue_comment(
  196. session = pagure_db,
  197. issue = context,
  198. comment = note['content'],
  199. user = author.username)
  200. #if isinstance(context, MergeRequest):
  201. # TODO
  202. # pass
  203. # If the "context" of the Note is not a local ticket or a local MR, this
  204. # note was created for a remote object. This is the case for example when a
  205. # user has sent a comment to a remote ticket, and we have received the
  206. # Activity because we are following that ticket.
  207. else:
  208. # Check if there is any local ticket or MR in the pagure database that
  209. # is used to track a remote object
  210. context = from_remote_uri(pagure_db, forgefed_graph, note['context'])
  211. # This is a new Note for a remote Ticket
  212. if isinstance(context, Ticket):
  213. author = test_or_set_remote_actor(pagure_db, forgefed_graph, note['attributedTo'])
  214. # Create the new comment to the ticket
  215. pagure.lib.query.add_issue_comment(
  216. session = pagure_db,
  217. issue = context,
  218. comment = note['content'],
  219. user = author.username)
  220. def action(func):
  221. """
  222. This function creates a decorator to be applied to the methods of class
  223. Actor. It represents an ActivityStream Action.
  224. The decorator will first execute the decorated function, and then schedule
  225. the delivery of the returned Activity.
  226. NOTE The function that is decorated with this decorator MUST return an
  227. Activity.
  228. :param func: The function to be decorated.
  229. """
  230. @functools.wraps(func)
  231. def decorator(self, *args, **kwargs):
  232. """
  233. Send an activity.
  234. By default, the Activity is sent to the Actor followers collection.
  235. To override this behavior it's possible to call the function like this:
  236. actor.follow(..., to=[], cc=[], bto=[], bcc=[])
  237. https://www.w3.org/TR/activitypub/#delivery
  238. """
  239. forgefed_graph = graph.Graph()
  240. # Create the activity from the Actor by executing the action (function)
  241. activity = func(self, *args, **kwargs)
  242. # Add publishing datetime
  243. # - use UTC
  244. # - remove microseconds, use HH:MM:SS only
  245. # - add timezone info. There is also .astimezone() but it seems to
  246. # return the wrong value when used with .utcnow(). Bug?
  247. # - convert to ISO 8601 format
  248. activity['published'] = format_datetime(datetime.datetime.utcnow())
  249. # By default we send the activity to the as:followers collection
  250. activity['to'] = [ self.followers_uri ]
  251. # Create the list of recipients
  252. recipients = []
  253. # TODO Check if it's possible to simplify this code by using rdflib.Graph
  254. for field in [ 'to', 'cc', 'bto', 'bcc' ]:
  255. if field in kwargs:
  256. activity[field] = kwargs[field]
  257. if field in activity:
  258. if isinstance(activity[field], str):
  259. recipients.append(activity[field])
  260. else:
  261. recipients.extend(activity[field])
  262. # Save a copy of the Activity in the database and add it to the Actor's
  263. # OUTBOX before sending it
  264. forgefed_graph.parse(data=json.dumps(activity), format='json-ld')
  265. forgefed_graph.commit()
  266. forgefed_graph.add_collection_item(self.outbox_uri, activity['id'])
  267. # Now we are ready to POST to the remote actors
  268. # Before sending, remove bto and bcc according to spec.
  269. # https://www.w3.org/TR/activitypub/#client-to-server-interactions
  270. activity.pop('bto', None)
  271. activity.pop('bcc', None)
  272. # Stop here if there are no recipients.
  273. # https://www.w3.org/TR/activitypub/#h-note-8
  274. if len(recipients) == 0:
  275. log.debug('No recipients. Activity will not be sent.')
  276. return
  277. # Make sure the local actor has a GPG key before POSTing anything. The
  278. # remote Actor will use the key for versifying the Activity.
  279. forgefed_graph.test_or_set_key(self.local_uri, self.publickey_uri)
  280. # Create a new Celery task for each recipient. Activities are POSTed
  281. # individually because if one request fails we don't want to resend
  282. # the same Activity to *all* the recipients.
  283. for recipient in recipients:
  284. log.debug('Scheduling new activity: id={} recipient={}'.format(
  285. activity['id'], recipient))
  286. tasks.activity.post.delay(
  287. activity = activity,
  288. recipient_uri = recipient,
  289. key_uri = self.publickey_uri,
  290. depth = settings.DELIVERY_DEPTH)
  291. forgefed_graph.disconnect()
  292. return decorator
  293. class ActivityStreamObject:
  294. """
  295. An ActivityStrem Object.
  296. """
  297. @property
  298. def uri(self):
  299. """
  300. The URI of this object.
  301. """
  302. remote_uri = self.remote_uri
  303. local_uri = self.local_uri
  304. if remote_uri: return remote_uri
  305. if local_uri: return local_uri
  306. return None
  307. @property
  308. def remote_uri(self):
  309. """
  310. The URI of the remote object if this object is only a local placeholder
  311. for a remote object.
  312. """
  313. g = graph.Graph()
  314. uri = g.value(rdflib.URIRef(self.local_uri), rdflib.OWL.sameAs)
  315. return str(uri) if uri else uri
  316. @property
  317. def is_remote(self):
  318. """
  319. Return True if this object is a local copy of a remote object. An example
  320. of such object would be a Ticket: a local copy is created such that users
  321. on the local instance can use the Pagure UI to interact with the remote
  322. Ticket.
  323. """
  324. return self.remote_uri != None
  325. @property
  326. def jsonld(self):
  327. return { '@context': activitypub.jsonld_context }
  328. class Actor(ActivityStreamObject):
  329. """
  330. An ActivityStream Actor.
  331. """
  332. def __repr__(self):
  333. raise Exception('Not implemented.')
  334. @property
  335. def inbox_uri(self):
  336. return self.local_uri + '/inbox'
  337. @property
  338. def outbox_uri(self):
  339. return self.local_uri + '/outbox'
  340. @property
  341. def followers_uri(self):
  342. return self.local_uri + '/followers'
  343. @property
  344. def following_uri(self):
  345. return self.local_uri + '/following'
  346. @property
  347. def publickey_uri(self):
  348. return self.local_uri + '/key.pub'
  349. @property
  350. def jsonld(self):
  351. return {
  352. **super().jsonld,
  353. 'id': self.local_uri,
  354. 'inbox': self.inbox_uri,
  355. 'outbox': self.outbox_uri,
  356. 'followers': self.followers_uri,
  357. 'following': self.following_uri,
  358. 'publicKey': self.publickey_uri }
  359. @action
  360. def accept(self, object_uri, *args, **kwargs):
  361. """
  362. Accept Activity.
  363. :param object_uri: URI of the ActivityPub object that was accepted.
  364. """
  365. return {
  366. '@context': activitypub.jsonld_context,
  367. 'id': self.outbox_uri + '/' + new_random_id(),
  368. 'type': 'Accept',
  369. 'actor': self.local_uri,
  370. 'object': object_uri,
  371. **kwargs }
  372. @action
  373. def create(self, object_uri, *args, **kwargs):
  374. """
  375. Create Activity.
  376. :param object_uri: URI of the ActivityPub object that was created.
  377. """
  378. return {
  379. '@context': activitypub.jsonld_context,
  380. 'id': self.outbox_uri + '/' + new_random_id(),
  381. 'type': 'Create',
  382. 'actor': self.local_uri,
  383. 'object': object_uri }
  384. @action
  385. def follow(self, object_uri, *args, **kwargs):
  386. """
  387. Follow Activity.
  388. :param object_uri: URI of the ActivityPub object to follow (an Actor).
  389. """
  390. return {
  391. '@context': activitypub.jsonld_context,
  392. 'id': self.outbox_uri + '/' + new_random_id(),
  393. 'type': 'Follow',
  394. 'actor': self.local_uri,
  395. 'object': object_uri }
  396. @action
  397. def offer(self, object, *args, **kwargs):
  398. """
  399. Offer Activity.
  400. :param object: Object to offer. Either a URI or a dictionary.
  401. """
  402. return {
  403. '@context': activitypub.jsonld_context,
  404. 'id': self.outbox_uri + '/' + new_random_id(),
  405. 'type': 'Offer',
  406. 'actor': self.local_uri,
  407. 'object': object }
  408. @action
  409. def update(self, object, *args, **kwargs):
  410. """
  411. Update Activity.
  412. :param object: The object that was updated.
  413. """
  414. return {
  415. '@context': activitypub.jsonld_context,
  416. 'id': self.outbox_uri + '/' + new_random_id(),
  417. 'type': 'Update',
  418. 'actor': self.local_uri,
  419. 'object': object }
  420. class Person(pagure.lib.model.User, Actor):
  421. """
  422. An ActivityStream Person.
  423. """
  424. def __repr__(self):
  425. return {
  426. 'class': type(self).__name__,
  427. 'type': 'Person',
  428. 'id': self.id,
  429. 'name': self.fullname,
  430. 'actor_uri': self.uri }
  431. @property
  432. def local_uri(self):
  433. return '{}/{}'.format(APP_URL, self.url_path)
  434. @property
  435. def jsonld(self):
  436. return {
  437. **super().jsonld,
  438. 'type': 'Person',
  439. 'name': self.fullname,
  440. 'preferredUsername': self.username }
  441. def handle_incoming_activity(self, activity):
  442. tasks.person.handle_incoming_activity.delay(self.id, activity)
  443. class Repository(pagure.lib.model.Project, Actor):
  444. """
  445. A ForgeFed Repository.
  446. """
  447. def __repr__(self):
  448. return {
  449. 'class': type(self).__name__,
  450. 'type': 'Repository',
  451. 'id': self.id,
  452. 'name': self.name,
  453. 'namespace': self.namespace,
  454. 'is_fork': self.is_fork,
  455. 'actor_uri': self.uri }
  456. @property
  457. def local_uri(self):
  458. return APP_URL + '/' + self.url_path + '.git'
  459. @property
  460. def jsonld(self):
  461. return {
  462. **super().jsonld,
  463. 'type': 'Repository',
  464. 'name': self.name,
  465. 'team': None }
  466. def handle_incoming_activity(self, activity):
  467. tasks.repository.handle_incoming_activity.delay(self.id, activity)
  468. class Projects(pagure.lib.model.Project, Actor):
  469. """
  470. A ForgeFed Project.
  471. """
  472. def __repr__(self):
  473. return {
  474. 'class': type(self).__name__,
  475. 'type': 'Project',
  476. 'id': self.id,
  477. 'name': self.name,
  478. 'namespace': self.namespace,
  479. 'actor_uri': self.uri }
  480. @property
  481. def local_uri(self):
  482. return APP_URL + '/' + self.url_path
  483. @property
  484. def jsonld(self):
  485. return {
  486. **super().jsonld,
  487. 'type': 'Project',
  488. 'name': self.name,
  489. 'preferredUsername': 'project/{}'.format(self.url_path) }
  490. def handle_incoming_activity(self, activity):
  491. tasks.project.handle_incoming_activity.delay(self.id, activity)
  492. class Ticket(pagure.lib.model.Issue, ActivityStreamObject):
  493. @property
  494. def local_uri(self):
  495. return '{}/{}/issue/{}'.format(APP_URL, self.project.url_path, self.id)
  496. @property
  497. def jsonld(self):
  498. return {
  499. **super().jsonld,
  500. 'id': self.local_uri,
  501. 'type': 'Ticket',
  502. 'context': '{}/{}'.format(APP_URL, self.project.url_path),
  503. 'attributedTo': '{}/{}'.format(APP_URL, self.user.url_path),
  504. 'summary': self.title,
  505. 'content': self.content,
  506. 'mediaType': 'text/plain',
  507. 'source': {
  508. 'content': self.content,
  509. 'mediaType': 'text/markdown; variant=CommonMark'
  510. },
  511. 'assignedTo': None,
  512. 'isResolved': False
  513. }
  514. class TicketComment(pagure.lib.model.IssueComment, ActivityStreamObject):
  515. @property
  516. def local_uri(self):
  517. return '{}/federation/ticket_comment/{}'.format(APP_URL, self.id)
  518. @property
  519. def jsonld(self):
  520. # Find the "context" of the comment (ie. the URI of the Ticket it belongs to)
  521. ticket = self.issue #copy.deepcopy(self.issue)
  522. ticket.__class__ = Ticket
  523. context = ticket.uri
  524. return {
  525. **super().jsonld,
  526. 'id': self.local_uri,
  527. 'type': 'Note',
  528. 'context': context,
  529. 'attributedTo': APP_URL + '/' + self.user.url_path,
  530. 'inReplyTo': None, # Pagure does not use nested comments
  531. 'mediaType': 'text/plain',
  532. 'content': self.comment,
  533. 'source': {
  534. 'mediaType': 'text/markdown; variant=Commonmark',
  535. 'content': self.comment
  536. },
  537. 'published': format_datetime(self.date_created)
  538. }
  539. class MRComment(pagure.lib.model.IssueComment, ActivityStreamObject):
  540. """
  541. Merge Request Comment.
  542. """
  543. @property
  544. def local_uri(self):
  545. return '{}/{}/issue/{}'.format(APP_URL, self.project.url_path, self.id)
  546. @property
  547. def jsonld(self):
  548. return {
  549. **super().jsonld,
  550. }