model.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698
  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. When defining new classes it's important to prefix them in order to avoid
  11. conflicts with Pagure classes.
  12. Motivation: Pagure (see pagure.lib.model) uses simple class names when
  13. it creates relationships for the SQLAlchemy ORM. For example the class Issue
  14. has the property:
  15. project = relation(
  16. "Project",
  17. foreign_keys=[project_id],
  18. remote_side=[Project.id],
  19. backref=backref("issues", cascade="delete, delete-orphan"),
  20. single_parent=True)
  21. If we register a new class Project with SQLAlchemy, the single string
  22. "Project" in the relation is not enough to disambiguate which class
  23. SQLAlchemy should use. So, in order to preserve the Pagure source code,
  24. we add a prefix to our plugin classes. Other info available at:
  25. https://docs.sqlalchemy.org/en/13/orm/extensions/declarative/relationships.html#evaluation-of-relationship-arguments
  26. """
  27. import copy
  28. import datetime
  29. import logging
  30. import pagure.config
  31. import pagure.lib.model
  32. import pagure.lib.query
  33. import urllib
  34. from Crypto.PublicKey import RSA
  35. from pagure.lib.model_base import BASE
  36. from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, \
  37. LargeBinary, UnicodeText, func
  38. from sqlalchemy import PrimaryKeyConstraint
  39. from sqlalchemy import create_engine
  40. from sqlalchemy.orm import relationship
  41. from sqlalchemy.orm.session import Session
  42. from . import APP_URL
  43. from . import activitypub
  44. from . import settings
  45. from . import tasks
  46. log = logging.getLogger(__name__)
  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(Project).filter(Project.name == repo_name)
  92. if repo_username:
  93. project = project.filter(Project.is_fork == True) \
  94. .filter(Project.user.has(Person.user == repo_username))
  95. else:
  96. project = project.filter(Project.is_fork == False)
  97. if repo_namespace:
  98. project = project.filter(Project.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_path(pagure_db, path):
  123. return from_local_uri(pagure_db, '{}{}'.format(APP_URL, path))
  124. def from_remote_uri(pagure_db, uri):
  125. # Check if the URI is an actual remote object
  126. if uri.startswith(APP_URL):
  127. return None
  128. # Get the URI of the local object that maps the remote object
  129. same_as = pagure_db.query(SameAs) \
  130. .filter(SameAs.remote_uri == uri) \
  131. .one_or_none()
  132. if not same_as:
  133. return None
  134. return from_uri(pagure_db, same_as.local_uri)
  135. def test_or_set_remote_actor(pagure_db, 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 uri: The URI of the remote actor.
  144. """
  145. # Fetch JSONLD of the remote actor
  146. actor = activitypub.fetch(uri)
  147. webfinger = '{}@{}'.format(actor['preferredUsername'], urllib.parse.urlparse(uri).netloc)
  148. # This will create a new user in the Pagure database if it doesn't exist
  149. user = pagure.lib.query.set_up_user(
  150. session = pagure_db,
  151. username = webfinger.replace('/', '+'),
  152. fullname = actor['name'],
  153. default_email = webfinger)
  154. # Return a Person object instead of the pagure User class
  155. person = pagure_db.query(Person) \
  156. .filter(Person.id == user.id) \
  157. .one_or_none()
  158. # Set a owl:sameAs link in the forgefed database, so that we know that this
  159. # person is only used to represent a remote user.
  160. pagure_db.merge(SameAs(
  161. local_uri = person.local_uri,
  162. remote_uri = uri))
  163. return person
  164. def test_or_set_remote_comment(pagure_db, uri):
  165. """
  166. This is the same as test_or_set_remote_actor() but for comments.
  167. :param pagure_db: A session to the pagure database.
  168. :param uri: The URI of the remote Note.
  169. """
  170. # If the URI of the comment is a URI to the local instance, we don't
  171. # need to do anything because the pagure database already contains the
  172. # comment.
  173. if uri.startswith(APP_URL):
  174. return
  175. # Fetch JSONLD of the remote Note
  176. note = activitypub.fetch(uri)
  177. # Check if we already have a local object for this remote comment
  178. if pagure_db.query(
  179. pagure_db.query(SameAs).filter(SameAs.local_uri == uri).exists()
  180. ).scalar():
  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. # The "context" property is used to specify if the Note is for a Ticket or a
  185. # MergeRequest
  186. if 'context' not in note:
  187. return
  188. # If the "context" of the Note (which can be a Ticker or a MergeRequest) is
  189. # a local URL, it means somebody has created a Note for an object in our
  190. # database. This is the case for example when a remote user is contributing
  191. # a comment to a local project.
  192. if note['context'].startswith(APP_URL):
  193. # Get the database object
  194. context = from_local_uri(pagure_db, note['context'])
  195. # This is a new Note for a local Ticket
  196. if isinstance(context, Ticket):
  197. author = test_or_set_remote_actor(pagure_db, note['attributedTo'])
  198. # Create the new comment to the ticket
  199. pagure.lib.query.add_issue_comment(
  200. session = pagure_db,
  201. issue = context,
  202. comment = note['content'],
  203. user = author.username)
  204. #if isinstance(context, MergeRequest):
  205. # TODO
  206. # pass
  207. # If the "context" of the Note is not a local ticket or a local MR, this
  208. # note was created for a remote object. This is the case for example when a
  209. # user has sent a comment to a remote ticket, and we have received the
  210. # Activity because we are following that ticket.
  211. else:
  212. # Check if there is any local ticket or MR in the pagure database that
  213. # is used to track a remote object
  214. context = from_remote_uri(pagure_db, note['context'])
  215. # This is a new Note for a remote Ticket
  216. if isinstance(context, Ticket):
  217. author = test_or_set_remote_actor(pagure_db, note['attributedTo'])
  218. # Create the new comment to the ticket
  219. pagure.lib.query.add_issue_comment(
  220. session = pagure_db,
  221. issue = context,
  222. comment = note['content'],
  223. user = author.username)
  224. ################################################################################
  225. # Extension of the Pagure model
  226. ################################################################################
  227. class ActivityStreamObject:
  228. """
  229. An ActivityStrem Object.
  230. """
  231. def _get_database_session(self):
  232. """
  233. Return the SQLAlchemy session associated with this instance.
  234. """
  235. return Session.object_session(self)
  236. @property
  237. def uri(self):
  238. """
  239. The URI of this object.
  240. """
  241. remote_uri = self.remote_uri
  242. local_uri = self.local_uri
  243. if remote_uri: return remote_uri
  244. if local_uri: return local_uri
  245. return None
  246. @property
  247. def remote_uri(self):
  248. """
  249. The URI of the remote object if this object is only a local placeholder
  250. for a remote object.
  251. """
  252. db = self._get_database_session()
  253. same_as = db.query(SameAs) \
  254. .filter(SameAs.local_uri == self.local_uri) \
  255. .one_or_none()
  256. return same_as.remote_uri if same_as else None
  257. @property
  258. def is_remote(self):
  259. """
  260. Return True if this object is a local copy of a remote object. An example
  261. of such object would be a comment. A local copy is created such that users
  262. on the local instance can use the Pagure UI to interact with the remotes.
  263. """
  264. return self.remote_uri != None
  265. class Actor(ActivityStreamObject):
  266. """
  267. An ActivityStream Actor.
  268. """
  269. def __new__(cls, *args, **kwargs):
  270. if cls is Actor:
  271. raise TypeError('Cannot instantiate model.Actor class directly.')
  272. return ActivityStreamObject.__new__(cls, *args, **kwargs)
  273. @property
  274. def inbox_uri(self):
  275. return self.local_uri + '/inbox'
  276. @property
  277. def outbox_uri(self):
  278. return self.local_uri + '/outbox'
  279. @property
  280. def followers_uri(self):
  281. return self.local_uri + '/followers'
  282. @property
  283. def following_uri(self):
  284. return self.local_uri + '/following'
  285. @property
  286. def publickey_uri(self):
  287. return self.local_uri + '/key.pub'
  288. @property
  289. def jsonld(self):
  290. return {
  291. '@context': activitypub.jsonld_context,
  292. 'id': self.local_uri,
  293. 'inbox': self.inbox_uri,
  294. 'outbox': self.outbox_uri,
  295. 'followers': self.followers_uri,
  296. 'following': self.following_uri,
  297. 'publicKey': self.publickey_uri }
  298. class Person(pagure.lib.model.User, Actor):
  299. """
  300. An ActivityStream Person.
  301. """
  302. @property
  303. def local_uri(self):
  304. return '{}/{}'.format(APP_URL, self.url_path)
  305. @property
  306. def jsonld(self):
  307. return {
  308. **super().jsonld,
  309. 'type': 'Person',
  310. 'name': self.fullname,
  311. 'preferredUsername': self.username }
  312. class ForgefedProject(pagure.lib.model.Project, Actor):
  313. """
  314. A ForgeFed Project.
  315. """
  316. @property
  317. def local_uri(self):
  318. return APP_URL + '/' + self.url_path
  319. @property
  320. def jsonld(self):
  321. return {
  322. **super().jsonld,
  323. 'type': 'Project',
  324. 'name': self.name,
  325. 'preferredUsername': 'project/{}'.format(self.url_path) }
  326. class Repository(pagure.lib.model.Project, Actor):
  327. """
  328. A ForgeFed Repository.
  329. """
  330. @property
  331. def local_uri(self):
  332. return APP_URL + '/' + self.url_path + '.git'
  333. @property
  334. def jsonld(self):
  335. return {
  336. **super().jsonld,
  337. 'type': 'Repository',
  338. 'name': self.name,
  339. 'team': None }
  340. class BugTracker(pagure.lib.model.Project, Actor):
  341. """
  342. A ForgeFed BugTracker.
  343. """
  344. @property
  345. def local_uri(self):
  346. return APP_URL + '/' + self.url_path + '/issues'
  347. @property
  348. def jsonld(self):
  349. return {
  350. **super().jsonld,
  351. 'type': 'BugTracker' }
  352. class Ticket(pagure.lib.model.Issue, ActivityStreamObject):
  353. @property
  354. def local_uri(self):
  355. return '{}/{}/issue/{}'.format(APP_URL, self.project.url_path, self.id)
  356. @property
  357. def jsonld(self):
  358. return {
  359. '@context': activitypub.jsonld_context,
  360. 'id': self.local_uri,
  361. 'type': 'Ticket',
  362. 'context': '{}/{}'.format(APP_URL, self.project.url_path),
  363. 'attributedTo': '{}/{}'.format(APP_URL, self.user.url_path),
  364. 'summary': self.title,
  365. 'content': self.content,
  366. 'mediaType': 'text/plain',
  367. 'source': {
  368. 'content': self.content,
  369. 'mediaType': 'text/markdown; variant=CommonMark'
  370. },
  371. 'assignedTo': None,
  372. 'isResolved': False
  373. }
  374. class TicketComment(pagure.lib.model.IssueComment, ActivityStreamObject):
  375. @property
  376. def local_uri(self):
  377. return '{}/federation/ticket_comment/{}'.format(APP_URL, self.id)
  378. @property
  379. def jsonld(self):
  380. # Find the "context" of the comment (ie. the URI of the Ticket it belongs to)
  381. ticket = self.issue #copy.deepcopy(self.issue)
  382. ticket.__class__ = Ticket
  383. context = ticket.uri
  384. return {
  385. '@context': activitypub.jsonld_context,
  386. 'id': self.local_uri,
  387. 'type': 'Note',
  388. 'context': context,
  389. 'attributedTo': APP_URL + '/' + self.user.url_path,
  390. 'inReplyTo': None, # Pagure does not use nested comments
  391. 'mediaType': 'text/plain',
  392. 'content': self.comment,
  393. 'source': {
  394. 'mediaType': 'text/markdown; variant=Commonmark',
  395. 'content': self.comment
  396. },
  397. 'published': activitypub.format_datetime(self.date_created)
  398. }
  399. class MRComment(pagure.lib.model.IssueComment, ActivityStreamObject):
  400. """
  401. Merge Request Comment.
  402. """
  403. @property
  404. def local_uri(self):
  405. return '{}/{}/issue/{}'.format(APP_URL, self.project.url_path, self.id)
  406. @property
  407. def jsonld(self):
  408. return {
  409. '@context': activitypub.jsonld_context,
  410. }
  411. ################################################################################
  412. # ForgeFed relations.
  413. # These tables are created/added to the Pagure database. They are used for
  414. # various tasks required by federation.
  415. ################################################################################
  416. class GpgKey(BASE, ActivityStreamObject):
  417. """
  418. This class represents an Actor GPG key that is used to sign HTTP POST
  419. requests.
  420. """
  421. __tablename__ = 'forgefed_gpg_key'
  422. # The URI of the key
  423. uri = Column(UnicodeText, primary_key=True, nullable=False)
  424. # The actor who owns the key
  425. actor_uri = Column(UnicodeText, nullable=False)
  426. # The private part of the key
  427. private = Column(LargeBinary, nullable=False)
  428. # The public part of the key
  429. public = Column(LargeBinary, nullable=False)
  430. # When was this key created
  431. created = Column(DateTime, nullable=False,
  432. default=lambda: datetime.datetime.now(datetime.timezone.utc))
  433. @property
  434. def jsonld(self):
  435. return {
  436. '@context': activitypub.jsonld_context,
  437. 'id': self.uri,
  438. 'type': 'CryptographicKey',
  439. 'owner': self.actor_uri,
  440. #'created': None,
  441. #'expires': None,
  442. #'revoked': None,
  443. #'privateKeyPem': self.private.decode('UTF-8'), DO NOT DISPLAY PRIVATE KEY
  444. 'publicKeyPem': self.public.decode('UTF-8')
  445. }
  446. @staticmethod
  447. def new_key():
  448. """
  449. Returns a new private/public key pair.
  450. """
  451. key = RSA.generate(settings.HTTP_SIGNATURE_KEY_BITS)
  452. return {
  453. 'private': key.export_key('PEM'),
  454. 'public': key.publickey().export_key('PEM')
  455. }
  456. @staticmethod
  457. def test_or_set(database, actor_uri, key_uri):
  458. """
  459. Test if an Actor already has a GPG key, otherwise automatically generate
  460. a new one.
  461. :param database: Since this function is static, we need a working session
  462. :param actor_uri: URI of the ActivityPub Actor
  463. :param key_uri: URI of the ActivityPub Key for the Actor
  464. """
  465. key = database.query(GpgKey) \
  466. .filter(GpgKey.uri == key_uri) \
  467. .one_or_none()
  468. if not key:
  469. key = GpgKey.new_key()
  470. database.add(GpgKey(uri = key_uri,
  471. actor_uri = actor_uri,
  472. private = key['private'],
  473. public = key['public']))
  474. database.commit()
  475. class Collection(BASE, ActivityStreamObject):
  476. """
  477. This class represents a list of items for a Collection.
  478. """
  479. __tablename__ = 'forgefed_collection'
  480. __table_args__ = (
  481. PrimaryKeyConstraint('uri', 'item'),
  482. )
  483. # The URI of the Collection.
  484. uri = Column(UnicodeText, nullable=False)
  485. # The URI of an item in the Collection.
  486. item = Column(UnicodeText, nullable=False)
  487. # When was this item added to the collection
  488. added = Column(DateTime, nullable=False,
  489. default=lambda: datetime.datetime.now(datetime.timezone.utc))
  490. @property
  491. def local_uri(self):
  492. return self.uri
  493. class SameAs(BASE):
  494. """
  495. This class is used to map links between local URIs and remote URIs.
  496. The reason is that we need to create local objects in Pagure to
  497. represents remote objects such as comments, so we keep track of which
  498. local objects are only a local representation of remote ones.
  499. """
  500. __tablename__ = 'forgefed_sameas'
  501. local_uri = Column(UnicodeText, primary_key=True, nullable=False)
  502. remote_uri = Column(UnicodeText, nullable=False)
  503. class Resource(BASE):
  504. """
  505. This is a relation used for saving copies of JSONLD documents. It stores a URI
  506. and its corresponding JSON document.
  507. It's used for storing Activities, as well as caching remote objects that we can
  508. query quickly without making a GET request (for example when displaying the list
  509. of Followers, we store the JSONLD document of the remote user).
  510. """
  511. __tablename__ = 'forgefed_resource'
  512. # The URI of the object.
  513. uri = Column(UnicodeText, primary_key=True, default=None)
  514. # The JSON-LD document of the object.
  515. document = Column(UnicodeText, nullable=False)
  516. @property
  517. def jsonld(self):
  518. return activitypub.Document(self.document)
  519. class Feed(BASE):
  520. """
  521. This class is used to store feeds about federation events. These feeds are
  522. simply displayed to the user in their federation page.
  523. NOTE: This only exists because Pagure feed is strictly linked (with a foreign key)
  524. to existing objects of the database, for example comments ID. Using the
  525. Pagure feed would require messing with the Pagure schema, therefore I
  526. decided not to use it. This means there's a separate feed for federation
  527. events only.
  528. """
  529. __tablename__ = 'forgefed_feed'
  530. # This only exists because SQLAlchemy requires a primary key, but this
  531. # primary key is not used anywhere.
  532. id = Column(Integer, primary_key=True)
  533. # The URI of the local Actor this feed item belongs to.
  534. actor_uri = Column(UnicodeText, nullable=False)
  535. # The content of the feed.
  536. content = Column(UnicodeText, nullable=False)
  537. # When the feed item was created.
  538. created = Column(DateTime, nullable=False,
  539. default=lambda: datetime.datetime.now(datetime.timezone.utc))
  540. # Automatically create the database tables on startup. The tables are first
  541. # checked for existence before any CREATE TABLE command is issued.
  542. # https://docs.sqlalchemy.org/en/13/orm/tutorial.html#create-a-schema
  543. db_url = pagure.config.config.get('DB_URL')
  544. if db_url.startswith('postgres'):
  545. engine = create_engine(db_url, echo=True, client_encoding="utf8", pool_recycle=3600)
  546. else:
  547. engine = create_engine(db_url, echo=True, pool_recycle=3600)
  548. BASE.metadata.create_all(engine)