123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698 |
- # -*- 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
-
- When defining new classes it's important to prefix them in order to avoid
- conflicts with Pagure classes.
- Motivation: Pagure (see pagure.lib.model) uses simple class names when
- it creates relationships for the SQLAlchemy ORM. For example the class Issue
- has the property:
- project = relation(
- "Project",
- foreign_keys=[project_id],
- remote_side=[Project.id],
- backref=backref("issues", cascade="delete, delete-orphan"),
- single_parent=True)
- If we register a new class Project with SQLAlchemy, the single string
- "Project" in the relation is not enough to disambiguate which class
- SQLAlchemy should use. So, in order to preserve the Pagure source code,
- we add a prefix to our plugin classes. Other info available at:
- https://docs.sqlalchemy.org/en/13/orm/extensions/declarative/relationships.html#evaluation-of-relationship-arguments
- """
- import copy
- import datetime
- import logging
- import pagure.config
- import pagure.lib.model
- import pagure.lib.query
- import urllib
- from Crypto.PublicKey import RSA
- from pagure.lib.model_base import BASE
- from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, \
- LargeBinary, UnicodeText, func
- from sqlalchemy import PrimaryKeyConstraint
- from sqlalchemy import create_engine
- from sqlalchemy.orm import relationship
- from sqlalchemy.orm.session import Session
- from . import APP_URL
- from . import activitypub
- from . import settings
- from . import tasks
- log = logging.getLogger(__name__)
- 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(Project).filter(Project.name == repo_name)
-
- if repo_username:
- project = project.filter(Project.is_fork == True) \
- .filter(Project.user.has(Person.user == repo_username))
- else:
- project = project.filter(Project.is_fork == False)
-
- if repo_namespace:
- project = project.filter(Project.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_path(pagure_db, path):
- return from_local_uri(pagure_db, '{}{}'.format(APP_URL, path))
- def from_remote_uri(pagure_db, 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
- same_as = pagure_db.query(SameAs) \
- .filter(SameAs.remote_uri == uri) \
- .one_or_none()
-
- if not same_as:
- return None
-
- return from_uri(pagure_db, same_as.local_uri)
- def test_or_set_remote_actor(pagure_db, 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 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 database, so that we know that this
- # person is only used to represent a remote user.
- pagure_db.merge(SameAs(
- local_uri = person.local_uri,
- remote_uri = uri))
-
- return person
- def test_or_set_remote_comment(pagure_db, uri):
- """
- This is the same as test_or_set_remote_actor() but for comments.
-
- :param pagure_db: A session to the pagure database.
- :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 pagure_db.query(
- pagure_db.query(SameAs).filter(SameAs.local_uri == uri).exists()
- ).scalar():
-
- 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...
-
- # The "context" property is used to specify if the Note is for a Ticket or a
- # MergeRequest
- if 'context' not in note:
- return
-
- # 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, 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, note['context'])
-
- # This is a new Note for a remote Ticket
- if isinstance(context, Ticket):
- author = test_or_set_remote_actor(pagure_db, 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)
- ################################################################################
- # Extension of the Pagure model
- ################################################################################
- class ActivityStreamObject:
- """
- An ActivityStrem Object.
- """
-
- def _get_database_session(self):
- """
- Return the SQLAlchemy session associated with this instance.
- """
-
- return Session.object_session(self)
-
- @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.
- """
-
- db = self._get_database_session()
- same_as = db.query(SameAs) \
- .filter(SameAs.local_uri == self.local_uri) \
- .one_or_none()
-
- return same_as.remote_uri if same_as else None
-
- @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 comment. A local copy is created such that users
- on the local instance can use the Pagure UI to interact with the remotes.
- """
-
- return self.remote_uri != None
- class Actor(ActivityStreamObject):
- """
- An ActivityStream Actor.
- """
-
- def __new__(cls, *args, **kwargs):
- if cls is Actor:
- raise TypeError('Cannot instantiate model.Actor class directly.')
-
- return ActivityStreamObject.__new__(cls, *args, **kwargs)
-
- @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 {
- '@context': activitypub.jsonld_context,
- 'id': self.local_uri,
- 'inbox': self.inbox_uri,
- 'outbox': self.outbox_uri,
- 'followers': self.followers_uri,
- 'following': self.following_uri,
- 'publicKey': self.publickey_uri }
- class Person(pagure.lib.model.User, Actor):
- """
- An ActivityStream Person.
- """
-
- @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 }
- class ForgefedProject(pagure.lib.model.Project, Actor):
- """
- A ForgeFed Project.
- """
-
- @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) }
- class Repository(pagure.lib.model.Project, Actor):
- """
- A ForgeFed Repository.
- """
-
- @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 }
- class BugTracker(pagure.lib.model.Project, Actor):
- """
- A ForgeFed BugTracker.
- """
-
- @property
- def local_uri(self):
- return APP_URL + '/' + self.url_path + '/issues'
-
- @property
- def jsonld(self):
- return {
- **super().jsonld,
- 'type': 'BugTracker' }
- 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 {
- '@context': activitypub.jsonld_context,
- '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 {
- '@context': activitypub.jsonld_context,
- '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': activitypub.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 {
- '@context': activitypub.jsonld_context,
- }
- ################################################################################
- # ForgeFed relations.
- # These tables are created/added to the Pagure database. They are used for
- # various tasks required by federation.
- ################################################################################
- class GpgKey(BASE, ActivityStreamObject):
- """
- This class represents an Actor GPG key that is used to sign HTTP POST
- requests.
- """
-
- __tablename__ = 'forgefed_gpg_key'
-
- # The URI of the key
- uri = Column(UnicodeText, primary_key=True, nullable=False)
-
- # The actor who owns the key
- actor_uri = Column(UnicodeText, nullable=False)
-
- # The private part of the key
- private = Column(LargeBinary, nullable=False)
-
- # The public part of the key
- public = Column(LargeBinary, nullable=False)
-
- # When was this key created
- created = Column(DateTime, nullable=False,
- default=lambda: datetime.datetime.now(datetime.timezone.utc))
-
- @property
- def jsonld(self):
- return {
- '@context': activitypub.jsonld_context,
- 'id': self.uri,
- 'type': 'CryptographicKey',
- 'owner': self.actor_uri,
- #'created': None,
- #'expires': None,
- #'revoked': None,
- #'privateKeyPem': self.private.decode('UTF-8'), DO NOT DISPLAY PRIVATE KEY
- 'publicKeyPem': self.public.decode('UTF-8')
- }
-
- @staticmethod
- def new_key():
- """
- Returns a new private/public key pair.
- """
-
- key = RSA.generate(settings.HTTP_SIGNATURE_KEY_BITS)
-
- return {
- 'private': key.export_key('PEM'),
- 'public': key.publickey().export_key('PEM')
- }
-
- @staticmethod
- def test_or_set(database, actor_uri, key_uri):
- """
- Test if an Actor already has a GPG key, otherwise automatically generate
- a new one.
-
- :param database: Since this function is static, we need a working session
- :param actor_uri: URI of the ActivityPub Actor
- :param key_uri: URI of the ActivityPub Key for the Actor
- """
-
- key = database.query(GpgKey) \
- .filter(GpgKey.uri == key_uri) \
- .one_or_none()
-
- if not key:
- key = GpgKey.new_key()
-
- database.add(GpgKey(uri = key_uri,
- actor_uri = actor_uri,
- private = key['private'],
- public = key['public']))
-
- database.commit()
- class Collection(BASE, ActivityStreamObject):
- """
- This class represents a list of items for a Collection.
- """
-
- __tablename__ = 'forgefed_collection'
- __table_args__ = (
- PrimaryKeyConstraint('uri', 'item'),
- )
-
- # The URI of the Collection.
- uri = Column(UnicodeText, nullable=False)
-
- # The URI of an item in the Collection.
- item = Column(UnicodeText, nullable=False)
-
- # When was this item added to the collection
- added = Column(DateTime, nullable=False,
- default=lambda: datetime.datetime.now(datetime.timezone.utc))
- @property
- def local_uri(self):
- return self.uri
- class SameAs(BASE):
- """
- This class is used to map links between local URIs and remote URIs.
- The reason is that we need to create local objects in Pagure to
- represents remote objects such as comments, so we keep track of which
- local objects are only a local representation of remote ones.
- """
-
- __tablename__ = 'forgefed_sameas'
-
- local_uri = Column(UnicodeText, primary_key=True, nullable=False)
- remote_uri = Column(UnicodeText, nullable=False)
- class Resource(BASE):
- """
- This is a relation used for saving copies of JSONLD documents. It stores a URI
- and its corresponding JSON document.
- It's used for storing Activities, as well as caching remote objects that we can
- query quickly without making a GET request (for example when displaying the list
- of Followers, we store the JSONLD document of the remote user).
- """
-
- __tablename__ = 'forgefed_resource'
-
- # The URI of the object.
- uri = Column(UnicodeText, primary_key=True, default=None)
-
- # The JSON-LD document of the object.
- document = Column(UnicodeText, nullable=False)
-
- @property
- def jsonld(self):
- return activitypub.Document(self.document)
- class Feed(BASE):
- """
- This class is used to store feeds about federation events. These feeds are
- simply displayed to the user in their federation page.
-
- NOTE: This only exists because Pagure feed is strictly linked (with a foreign key)
- to existing objects of the database, for example comments ID. Using the
- Pagure feed would require messing with the Pagure schema, therefore I
- decided not to use it. This means there's a separate feed for federation
- events only.
- """
-
- __tablename__ = 'forgefed_feed'
-
- # This only exists because SQLAlchemy requires a primary key, but this
- # primary key is not used anywhere.
- id = Column(Integer, primary_key=True)
-
- # The URI of the local Actor this feed item belongs to.
- actor_uri = Column(UnicodeText, nullable=False)
-
- # The content of the feed.
- content = Column(UnicodeText, nullable=False)
-
- # When the feed item was created.
- created = Column(DateTime, nullable=False,
- default=lambda: datetime.datetime.now(datetime.timezone.utc))
- # Automatically create the database tables on startup. The tables are first
- # checked for existence before any CREATE TABLE command is issued.
- # https://docs.sqlalchemy.org/en/13/orm/tutorial.html#create-a-schema
- db_url = pagure.config.config.get('DB_URL')
- if db_url.startswith('postgres'):
- engine = create_engine(db_url, echo=True, client_encoding="utf8", pool_recycle=3600)
- else:
- engine = create_engine(db_url, echo=True, pool_recycle=3600)
- BASE.metadata.create_all(engine)
|