class APObjectTag(db.Model): id = db.Column(db.Integer, primary_key=True) type = db.Column(db.Enum(APObjectType)) href = db.Column(db.String(256)) name = db.Column(db.String(64)) ap_object_id = db.Column(db.ForeignKey('ap_object.id'), nullable=False) ap_object = db.relationship('APObject', foreign_keys=[ap_object_id], backref=db.backref( 'tags', cascade='all, delete-orphan')) def __init__(self, ap_object_id, _type, href, name): self.ap_object_id = ap_object_id self.type = _type self.href = href self.name = name def to_dict(self): output = {'type': self.type.value} if self.href is not None: output['href'] = self.href if self.name is not None: output['name'] = self.name return output
class Following(db.Model): ''' Representation of who a user is following both locally and federally. This model also records which ActivityPub outbox page the follower most recently requested to view for a given leader. ''' id = db.Column(db.Integer, nullable=False, primary_key=True) approved = db.Column(db.Boolean, nullable=False) leader = db.Column(db.String(256), nullable=False) followers_collection = db.Column(db.String(256), nullable=False) follower_id = db.Column(db.Integer, db.ForeignKey('actor.id'), nullable=False) follower = db.relationship('Actor', backref='following') def __init__(self, follower_id, leader, followers_collection, approved=False): self.follower_id = follower_id self.leader = leader self.followers_collection = followers_collection self.approved = approved
class Notification(db.Model): id = db.Column(db.Integer, primary_key=True) actor_id = db.Column(db.Integer, db.ForeignKey('ap_object.id')) content = db.Column(db.String(256), nullable=False) date = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) type = db.Column(db.String(16), nullable=False) actor = db.relationship('Actor') def __init__(self, actor, content, _type): ''' actor: Actor to which this notification belongs content (str): Message contents of the notification _type (str): Type of notification (ie, Follow, Dislike, Mention, etc.) ''' self.actor_id = actor.id self.content = content self.type = _type def to_dict(self): return { 'id': self.id, 'content': self.content, 'published': xsd_datetime(self.date), 'type': self.type }
class User(db.Model): id = db.Column(db.Integer, nullable=False, primary_key=True) username = db.Column(db.String(255), nullable=False) password_hash = db.Column(db.String(255), nullable=False) actors = db.relationship('Actor') def __init__(self, username, password): self.username = username.lower() self.password_hash = hashpw(bytes(password, 'utf-8'), gensalt())
class User(db.Model): id = db.Column(db.Integer, nullable=False, primary_key=True) username = db.Column(db.String(32), unique=True, nullable=False) password_hash = db.Column(db.String(255), nullable=False) primary_actor_id = db.Column(db.Integer, db.ForeignKey('actor.id')) primary_actor = db.relationship('Actor', foreign_keys=[primary_actor_id]) def __init__(self, username, password): self.username = username.lower() self.password_hash = hashpw(bytes(password, 'utf-8'), gensalt())
class APObjectRecipient(db.Model): id = db.Column(db.Integer, primary_key=True) ap_object_id = db.Column(db.Integer, db.ForeignKey('ap_object.id'), nullable=False) method = db.Column(db.String(3), nullable=False) recipient = db.Column(db.String(256), nullable=False) ap_object = db.relationship('APObject', backref='recipients') def __init__(self, ap_object_id, method, recipient): self.ap_object_id = ap_object_id self.method = method self.recipient = recipient
class APObjectRecipient(db.Model): id = db.Column(db.Integer, primary_key=True) ap_object_id = db.Column(db.Integer, db.ForeignKey('ap_object.id'), nullable=False) method = db.Column(db.String(3), nullable=False) recipient = db.Column(db.String(256), nullable=False) ap_object = db.relationship( 'APObject', foreign_keys=[ap_object_id], backref=db.backref('recipients', cascade='all, delete-orphan')) def __init__(self, method, recipient): self.method = method self.recipient = recipient
class Note(db.Model): id = db.Column(db.Integer, nullable=False, primary_key=True) actor_id = db.Column(db.Integer, db.ForeignKey('actor.id'), nullable=False) content = db.Column(db.String(1024), nullable=False) author = db.relationship('Actor') published = db.Column(db.DateTime, default=datetime.utcnow) def to_dict(self): return {}
class Actor(db.Model): id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey('user.id')) public_key = db.Column(db.Text(16639)) #PEM format private_key = db.Column(db.Text(16639)) #PEM format username = db.Column(db.String(32), nullable=False) user = db.relationship('User') notes = db.relationship('Note') def __init__(self, *args, **kwargs): if kwargs.get('user_id') != None: self.user_id = kwargs.get('user_id') elif kwargs.get('user') != None: self.user_id = user.id else: raise Exception( 'Instantiating an Actor requires either a user object or user id. ' ) if kwargs.get('username') != None: self.username = kwargs.get('username') def to_dict(self): api_url = config['api_url'] username = self.username return { '@context': [ 'https://www.w3.org/ns/activitystreams', 'https://w3id.org/security/v1' ], 'id': f'{api_url}/actors/{username}', 'type': 'Person', 'inbox': f'{api_url}/actors/{username}/inbox', 'outbox': f'{api_url}/actors/{username}/outbox', 'followers': f'{api_url}/actors/{username}/followers', 'following': f'{api_url}/actors/{username}/following', 'liked': f'{api_url}/actors/{username}/liked', 'preferredUsername': self.username, 'publicKey': { 'actor': f'{api_url}/actors/{username}', 'id': f'{api_url}/actors/{username}#main-key', 'publicKeyPem': self.public_key } }
class APObjectAttributedTo(db.Model): id = db.Column(db.Integer, primary_key=True) internal_actor_id = db.Column(db.ForeignKey('actor.id')) external_actor_id = db.Column(db.String(1024)) ap_object_id = db.Column(db.Integer, db.ForeignKey('ap_object.id'), nullable=False) ap_object = db.relationship('APObject', uselist=False, foreign_keys=[ap_object_id]) internal_actor = db.relationship('Actor', foreign_keys=[internal_actor_id])
class FollowedBy(db.Model): ''' Representation of who is following a local Vagabond actor. The inboxes (and shared inboxes, if they exist) of the followers are stored in this table as well. ''' id = db.Column(db.Integer, primary_key=True) leader_id = db.Column(db.Integer, db.ForeignKey('ap_object.id'), nullable=False) leader = db.relationship('Actor', backref='followed_by') approved = db.Column(db.Boolean, nullable=False) follower = db.Column(db.String(256), nullable=False) #URL of follower follower_inbox = db.Column(db.String(256), nullable=False) follower_shared_inbox = db.Column(db.String(256), nullable=True) def __init__(self, leader_id, follower, follower_inbox, follower_shared_inbox=None, approved=True): ''' leader_id: int follower: str URL of the follower follower_inbox: str ?follower_shared_inbox: str ''' self.leader_id = leader_id self.follower = follower self.follower_inbox = follower_inbox self.follower_shared_inbox = follower_shared_inbox self.approved = approved
class APObjectAttributedTo(db.Model): id = db.Column(db.Integer, primary_key=True) internal_actor_id = db.Column(db.ForeignKey('ap_object.id')) external_actor_id = db.Column(db.String(1024)) ap_object_id = db.Column(db.Integer, db.ForeignKey('ap_object.id'), nullable=False) ap_object = db.relationship('APObject', foreign_keys=[ap_object_id], backref=db.backref( 'attributions', cascade='all, delete-orphan'))
class Actor(APObject): id = db.Column(db.Integer, db.ForeignKey('ap_object.id'), primary_key=True) username = db.Column(db.String(32), unique=True, nullable=False) public_key = db.Column(db.Text(16639)) private_key = db.Column(db.Text(16639)) user_id = db.Column(db.Integer, db.ForeignKey('user.id')) user = db.relationship('User', backref='actors', foreign_keys=[user_id]) __mapper_args__ = {'polymorphic_identity': APObjectType.PERSON} def __init__(self, username, user=None, user_id=None): self.username = username if user_id is not None: self.user_id = user_id elif user is not None: self.user_id = user.id else: raise Exception( 'Instantiating an Actor requires either a user object or user id. ' ) key = RSA.generate(2048) self.private_key = key.export_key() self.public_key = key.publickey().export_key() self.type = APObjectType.PERSON def to_dict(self): api_url = config['api_url'] username = self.username output = super().to_dict() output['id'] = f'{api_url}/actors/{username}' output['inbox'] = f'{api_url}/actors/{username}/inbox' output['outbox'] = f'{api_url}/actors/{username}/outbox' output['followers'] = f'{api_url}/actors/{username}/followers' output['following'] = f'{api_url}/actors/{username}/following' output['liked'] = f'{api_url}/actors/{username}/liked' output['preferredUsername'] = self.username output['endpoints'] = {'sharedInbox': f'{api_url}/inbox'} output['publicKey'] = { 'actor': f'{api_url}/actors/{username}', 'id': f'{api_url}/actors/{username}#main-key', 'publicKeyPem': self.public_key } return output
class Actor(APObject): username = db.Column(db.String(32), unique=True) user_id = db.Column(db.Integer, db.ForeignKey('user.id')) user = db.relationship('User', backref='actors', foreign_keys=[user_id]) __mapper_args__ = {'polymorphic_identity': APObjectType.PERSON} def __init__(self, username, user=None, user_id=None): self.username = username if user_id is not None: self.user_id = user_id elif user is not None: self.user_id = user.id else: raise Exception( 'Instantiating an Actor requires either a user object or user id. ' ) self.type = APObjectType.PERSON def to_dict(self): api_url = os.environ['API_URL'] username = self.username output = super().to_dict() output['id'] = f'{api_url}/actors/{username}' output['inbox'] = f'{api_url}/actors/{username}/inbox' output['outbox'] = f'{api_url}/actors/{username}/outbox' output['followers'] = f'{api_url}/actors/{username}/followers' output['following'] = f'{api_url}/actors/{username}/following' output['liked'] = f'{api_url}/actors/{username}/liked' output['preferredUsername'] = self.username output['endpoints'] = {'sharedInbox': f'{api_url}/inbox'} output['publicKey'] = { 'actor': f'{api_url}/actors/{username}', 'id': f'{api_url}/actors/{username}#main-key', 'publicKeyPem': os.environ['PUBLIC_KEY'] } return output
class APObject(db.Model): ''' Superclass for all ActivityPub objects including instances of Activity ''' id = db.Column(db.Integer, primary_key=True) external_id = db.Column(db.String(256), unique=True) in_reply_to_internal_id = db.Column(db.ForeignKey('ap_object.id')) in_reply_to_external_id = db.Column(db.String(256)) content = db.Column(db.String(4096)) published = db.Column(db.DateTime, default=datetime.utcnow) type = db.Column(db.Enum(APObjectType)) #attributedTo property internal_author_id = db.Column(db.Integer, db.ForeignKey('ap_object.id')) external_author_id = db.Column(db.String(256)) __mapper_args__ = { 'polymorphic_identity': APObjectType.OBJECT, 'polymorphic_on': type } @staticmethod def get_object_from_url(url): ''' Takes the provided URL and attempts to locate an object with the matching external_id. This method works even for locally stored objects without an external_id property by parsing the provided URL and extracting the ID. ''' api_url = os.environ['API_URL'] if url.replace(api_url, '') != url: url = url.replace(api_url, '') splits = url.split('/') if len(splits) == 3: if splits[1] == 'actors': try: username = splits[2] return db.session.query(vagabond.models.Actor).filter( db.func.lower(username) == db.func.lower( vagabond.models.Actor.username)).first() except: return None elif splits[1] == 'objects': try: _id = int(splits[2]) return db.session.query( vagabond.models.APObject).get(_id) except: return None else: obj = db.session.query(APObject).filter( APObject.external_id == url).first() return obj def set_in_reply_to(self, in_reply_to): ''' Sets the inReplyTo propety of this APObject ''' if isinstance(in_reply_to, dict): if 'id' in in_reply_to: self.in_reply_to_external_id = in_reply_to['id'] obj = APObject.get_object_from_url(in_reply_to['id']) if obj is not None: self.in_reply_to_internal_id = obj.id else: raise Exception( 'Cannot set inReplyTo property: provided dictionary lacks \'id\' property.' ) elif isinstance(in_reply_to, str): self.in_reply_to_external_id = in_reply_to obj = APObject.get_object_from_url(in_reply_to) if obj is not None: self.in_reply_to_internal_id = obj.id elif (isinstance(in_reply_to, db.Model)): self.in_reply_to_external_id = in_reply_to.to_dict()['id'] self.in_reply_to_internal_id = in_reply_to.id def to_dict(self): api_url = os.environ['API_URL'] output = { '@context': ["https://www.w3.org/ns/activitystreams"], 'type': self.type.value, } if self.external_id is not None: output['id'] = self.external_id else: output['id'] = f'{api_url}/objects/{self.id}' #inReplyTo - what this object is in reply to if self.in_reply_to_internal_id is not None: in_reply_to = db.session.query(APObject).get( self.in_reply_to_internal_id) if in_reply_to is not None: output['inReplyTo'] = in_reply_to.to_dict() elif self.in_reply_to_external_id is not None: output['inReplyTo'] = self.in_reply_to_external_id #inReplyTo - what objects are a reply to this one if self.type == APObjectType.NOTE and self.id is not None: total_items = db.session.query(APObject).filter( APObject.in_reply_to_internal_id == self.id).count() if total_items > 0: output['replies'] = f'{api_url}/objects/{self.id}/replies' #attributedTo if hasattr( self, 'external_author_id') and self.external_author_id is not None: output['attributedTo'] = self.external_author_id elif hasattr( self, 'internal_author_id') and self.internal_author_id is not None: attributed_to = db.session.query(APObject).get( self.internal_author_id) if attributed_to is not None: output['attributedTo'] = attributed_to.to_dict()['id'] #tag property if len(self.tags) > 0: output['tag'] = [] for tag in self.tags: output['tag'].append(tag.to_dict()) if hasattr(self, 'content') and self.content is not None: output['content'] = self.content if hasattr(self, 'published'): output['published'] = xsd_datetime(self.published) if hasattr(self, 'recipients'): for recipient in self.recipients: if recipient.method not in output: output[recipient.method] = [] output[recipient.method].append(recipient.recipient) return output def add_tag(self, tag): ''' def __init__(self, ap_object_id, type, href, name): Adds a tag to this object. tag: dict | APObjectTag ''' if isinstance(tag, dict): _type = None if tag['type'] == 'Mention': self.tags.append( APObjectTag(self.id, APObjectType.MENTION, tag.get('href'), tag.get('name'))) else: raise Exception('Only supported tag type is Mention') elif isinstance(tag, APObjectTag): self.tags.append(tag) else: raise Exception( 'APObject#add_tag method only accepts dictionaries and instances of APObjectTag as input. Some other type was provided.' ) def add_recipient(self, method, recipient): ''' method = 'to' | 'bto' | 'cc' | 'bcc' recipient = Actor URL ID Adds an actor as a recipient of this object using either the to, bto, cc, or bcc ActivityPub fields. ''' method = method.lower() if method != 'to' and method != 'bto' and method != 'cc' and method != 'bcc': raise Exception( "Only acceptable values for APObject#add_recipient are 'to', 'bto', 'cc', and 'bcc'" ) self.recipients.append(APObjectRecipient(method, recipient)) def add_all_recipients(self, obj: dict): ''' Takes a dictionary object which contains some combination of the 'to', 'bto', 'cc', and 'bcc' fields and adds the intended recipients as recipients of this object. The four aformentioned fields can be either strings or lists. ''' keys = ['to', 'bto', 'cc', 'bcc'] for key in keys: recipients = obj.get(key) if recipients is not None: if isinstance(recipients, str): recipients = [recipients] elif isinstance(recipients, list) is not True: raise Exception( f'APObject#add_all_recipients method given an object whose {key} value was neither a string nor an array.' ) #prevent duplicate entries uniques = [] for recipient in recipients: if recipients not in uniques: uniques.append(recipient) for recipient in uniques: self.add_recipient(key, recipient) def attribute_to(self, author): ''' author: str | Model | int A string indicates that the object is being attributed to an external actor while a SQLAlchemy model or integer indicates a local actor. ''' #TODO: use get_object_from_url to set internal author id if isinstance(author, str): self.external_author_id = author elif isinstance(author, db.Model): self.internal_author_id = author.id elif isinstance(author, int): self.internal_author_id = author
class Activity(APObject): internal_object_id = db.Column(db.ForeignKey('ap_object.id')) external_object_id = db.Column(db.String(1024)) internal_actor_id = db.Column(db.ForeignKey('ap_object.id')) external_actor_id = db.Column(db.String(1024)) internal_object = db.relationship('APObject', foreign_keys=[internal_object_id], remote_side=APObject.id, backref=db.backref('activities', cascade='all, delete-orphan')) __mapper_args__ = { 'polymorphic_identity': APObjectType.ACTIVITY } def set_object(self, obj): ''' Input: vagabond.models.APObject | str | dict Takes an instance of APObject, a URL representing the object, or a dictionary representing the object and wraps this instance of Activity around it. ''' if isinstance(obj, db.Model): self.internal_object_id = obj.id elif isinstance(obj, str): interal_object = APObject.get_object_from_url(obj) if interal_object is not None: self.internal_object_id = interal_object.id self.external_object_id = obj elif isinstance(obj, dict) and 'id' in obj: interal_object = APObject.get_object_from_url(obj['id']) if interal_object is not None: self.internal_object_id = interal_object.id self.external_object_id = obj['id'] else: raise Exception('Activity#set_object method requires an APObject, a string, or a dictionary') def set_actor(self, actor): ''' Input: vagabond.models.Actor | str | dict ''' if isinstance(actor, db.Model): self.internal_actor_id = actor.id elif isinstance(actor, str): self.external_actor_id = actor elif isinstance(actor, dict) and 'id' in actor: self.external_actor_id = actor['id'] else: raise Exception('Activity#set_actor method requires an Actor, a string, or a dictionary') def to_dict(self): output = super().to_dict() if self.internal_object_id is not None: _object = db.session.query(APObject).get(self.internal_object_id) if _object is not None: output['object'] = _object.to_dict() elif self.external_object_id is not None: output['object'] = self.external_object_id else: raise Exception('Activites must have an internal or external object.') if self.internal_actor_id is not None: output['actor'] = db.session.query(APObject).get(self.internal_actor_id).to_dict()['id'] elif self.external_actor_id is not None: output['actor'] = self.external_actor_id else: raise Exception('Activies must have an internal or external actor') return output
class Activity(APObject): id = db.Column(db.Integer, db.ForeignKey('ap_object.id'), primary_key=True) internal_object_id = db.Column(db.ForeignKey('ap_object.id')) external_object_id = db.Column(db.String(1024)) internal_actor_id = db.Column(db.ForeignKey('actor.id')) external_actor_id = db.Column(db.String(1024)) actor = db.relationship('Actor', foreign_keys=[internal_actor_id ]) #Person performing the activity object = db.relationship('APObject', foreign_keys=internal_object_id, uselist=False) __mapper_args__ = { 'polymorphic_identity': APObjectType.ACTIVITY, 'inherit_condition': id == APObject.id } def set_object(self, obj): ''' Input: vagabond.models.APObject | str | dict Takes an instance of APObject, a URL representing the object, or a dictionary representing the object and wraps this instance of Activity around it. ''' if isinstance(obj, db.Model): self.internal_object_id = obj.id elif isinstance(obj, str): self.external_object_id = obj elif isinstance(obj, dict) and 'id' in obj: self.external_object_id = obj['id'] else: raise Exception( 'Activity#set_object method requires an APObject, a string, or a dictionary' ) def set_actor(self, actor): ''' Input: vagabond.models.Actor | str | dict ''' if isinstance(actor, db.Model): self.internal_actor_id = actor.id elif isinstance(actor, str): self.external_actor_id = actor elif isinstance(actor, dict) and 'id' in actor: self.external_actor_id = actor['id'] else: raise Exception( 'Activity#set_actor method requires an Actor, a string, or a dictionary' ) def to_dict(self): output = super().to_dict() if self.object is not None: output['object'] = self.object.to_dict() elif self.external_object_id is not None: output['object'] = self.external_object_id else: raise Exception( 'Activites must have an internal or external object.') if self.internal_actor_id is not None: output[ 'actor'] = f'{config["api_url"]}/actors/{self.actor.username}' elif self.external_actor_id is not None: output['actor'] = self.external_actor_id else: raise Exception('Activies must have an internal or external actor') return output
class APObject(db.Model): ''' Superclass for all ActivityPub objects including instances of Activity ''' id = db.Column(db.Integer, primary_key=True) external_id = db.Column(db.String(256), unique=True) context = ["https://www.w3.org/ns/activitystreams"] content = db.Column(db.String(4096)) published = db.Column(db.DateTime, default=datetime.utcnow) type = db.Column(db.Enum(APObjectType)) attributed_to = db.relationship('APObjectAttributedTo', uselist=False) __mapper_args__ = { 'polymorphic_identity': APObjectType.OBJECT, 'polymorphic_on': type } def to_dict(self): api_url = config['api_url'] output = { '@context': self.context, 'type': self.type.value, } if self.external_id is not None: output['id'] = self.external_id else: output['id'] = f'{api_url}/objects/{self.id}' if self.attributed_to is not None: if self.attributed_to.internal_actor_id is not None: output[ 'attributedTo'] = f'{api_url}/actors/{self.attributed_to.internal_actor.username}' elif self.attributed_to.external_actor_id is not None: output['attributedTo'] = self.attributed_to.external_actor_id if self.content is not None: output['content'] = self.content if self.published is not None: output['published'] = xsd_datetime(self.published) if hasattr(self, 'recipients'): for recipient in self.recipients: if output.get(recipient.method) is None: output[recipient.method] = [recipient.recipient] else: output[recipient.method].append(recipient.recipient) return output def add_recipient(self, method: str, recipient): ''' method = 'to' | 'bto' | 'cc' | 'bcc' recipient = Actor URL ID Adds an actor as a recipient of this object using either the to, bto, cc, or bcc ActivityPub fields. This method adds records to the database but does not commit or flush them. ''' method = method.lower() if method != 'to' and method != 'bto' and method != 'cc' and method != 'bcc': raise Exception( "Only acceptable values for APObject#add_recipient are 'to', 'bto', 'cc', and 'bcc'" ) db.session.add((APObjectRecipient(self.id, method, recipient))) def add_all_recipients(self, obj: dict): ''' Takes a dictionary object which contains some combination of the 'to', 'bto', 'cc', and 'bcc' fields and adds the intended recipients as recipients of this object. The four aformentioned fields can be either strings or lists. ''' keys = ['to', 'bto', 'cc', 'bcc'] for key in keys: value = obj.get(key) if value is not None: if isinstance(value, str): value = [value] elif isinstance(value, list) is not True: raise Exception( f'APObject#add_all_recipients method given an object whose {key} value was neither a string nor an array.' ) for _value in value: self.add_recipient(key, _value) def add_to_inbox(self, actor): ''' actor: Vagabond.models.Actor | int Puts this object into the inbox of a local actor. ''' def attribute_to(self, author): ''' author: str | Model | int Creates an instance of APObjectAttributedTo that represents an attribution to the input id, external actor URL, or vagabond.models.Actor instance. A string indicates that the object is being attributed to an external actor while a SQLAlchemy model or integer indicates a local actor. The newly created instance of APObjectAttributedTo is added to the database session, but not committed or flushed. ''' attribution = APObjectAttributedTo() attribution.ap_object_id = self.id if isinstance(author, str): attribution.external_actor_id = author elif isinstance(author, db.Model): attribution.internal_actor_id = author.id elif isinstance(author, int): attribution.internal_actor_id = author db.session.add(attribution)