diff --git a/bookwyrm/activitypub/__init__.py b/bookwyrm/activitypub/__init__.py index eee9345d..b5b124ec 100644 --- a/bookwyrm/activitypub/__init__.py +++ b/bookwyrm/activitypub/__init__.py @@ -2,7 +2,7 @@ import inspect import sys -from .base_activity import ActivityEncoder, PublicKey, Signature +from .base_activity import ActivityEncoder, Signature from .base_activity import Link, Mention from .base_activity import ActivitySerializerError, resolve_remote_id from .image import Image @@ -10,7 +10,7 @@ from .note import Note, GeneratedNote, Article, Comment, Review, Quotation from .note import Tombstone from .interaction import Boost, Like from .ordered_collection import OrderedCollection, OrderedCollectionPage -from .person import Person +from .person import Person, PublicKey from .book import Edition, Work, Author from .verbs import Create, Delete, Undo, Update from .verbs import Follow, Accept, Reject diff --git a/bookwyrm/activitypub/base_activity.py b/bookwyrm/activitypub/base_activity.py index 9944e368..9e1b5b82 100644 --- a/bookwyrm/activitypub/base_activity.py +++ b/bookwyrm/activitypub/base_activity.py @@ -13,7 +13,6 @@ from django.db.models.fields import DateTimeField from django.db.models.fields.files import ImageFileDescriptor from django.db.models.query_utils import DeferredAttribute from django.utils import timezone -import requests from bookwyrm.connectors import ConnectorException, get_data, get_image @@ -41,14 +40,6 @@ class Mention(Link): type: str = 'Mention' -@dataclass -class PublicKey: - ''' public key block ''' - id: str - owner: str - publicKeyPem: str - - @dataclass class Signature: ''' public key block ''' diff --git a/bookwyrm/activitypub/person.py b/bookwyrm/activitypub/person.py index e7d720ec..88349c02 100644 --- a/bookwyrm/activitypub/person.py +++ b/bookwyrm/activitypub/person.py @@ -2,9 +2,18 @@ from dataclasses import dataclass, field from typing import Dict -from .base_activity import ActivityObject, PublicKey +from .base_activity import ActivityObject from .image import Image + +@dataclass(init=False) +class PublicKey(ActivityObject): + ''' public key block ''' + owner: str + publicKeyPem: str + type: str = 'PublicKey' + + @dataclass(init=False) class Person(ActivityObject): ''' actor activitypub json ''' diff --git a/bookwyrm/migrations/0017_auto_20201130_1819.py b/bookwyrm/migrations/0017_auto_20201130_1819.py new file mode 100644 index 00000000..bab454cb --- /dev/null +++ b/bookwyrm/migrations/0017_auto_20201130_1819.py @@ -0,0 +1,188 @@ +# Generated by Django 3.0.7 on 2020-11-30 18:19 + +import bookwyrm.models.base_model +import bookwyrm.models.fields +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + +def copy_rsa_keys(app_registry, schema_editor): + db_alias = schema_editor.connection.alias + users = app_registry.get_model('bookwyrm', 'User') + keypair = app_registry.get_model('bookwyrm', 'KeyPair') + for user in users.objects.using(db_alias): + if user.public_key or user.private_key: + user.key_pair = keypair.objects.create( + private_key=user.private_key, + public_key=user.public_key + ) + user.save() + +class Migration(migrations.Migration): + + dependencies = [ + ('bookwyrm', '0016_auto_20201129_0304'), + ] + operations = [ + migrations.CreateModel( + name='KeyPair', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_date', models.DateTimeField(auto_now_add=True)), + ('updated_date', models.DateTimeField(auto_now=True)), + ('remote_id', bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id])), + ('private_key', models.TextField(blank=True, null=True)), + ('public_key', bookwyrm.models.fields.TextField(blank=True, null=True)), + ], + options={ + 'abstract': False, + }, + bases=(bookwyrm.models.base_model.ActivitypubMixin, models.Model), + ), + migrations.AddField( + model_name='user', + name='followers', + field=bookwyrm.models.fields.ManyToManyField(related_name='following', through='bookwyrm.UserFollows', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='author', + name='remote_id', + field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), + ), + migrations.AlterField( + model_name='book', + name='remote_id', + field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), + ), + migrations.AlterField( + model_name='connector', + name='remote_id', + field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), + ), + migrations.AlterField( + model_name='favorite', + name='remote_id', + field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), + ), + migrations.AlterField( + model_name='federatedserver', + name='remote_id', + field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), + ), + migrations.AlterField( + model_name='image', + name='remote_id', + field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), + ), + migrations.AlterField( + model_name='notification', + name='remote_id', + field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), + ), + migrations.AlterField( + model_name='readthrough', + name='remote_id', + field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), + ), + migrations.AlterField( + model_name='shelf', + name='remote_id', + field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), + ), + migrations.AlterField( + model_name='shelfbook', + name='remote_id', + field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), + ), + migrations.AlterField( + model_name='status', + name='remote_id', + field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), + ), + migrations.AlterField( + model_name='tag', + name='remote_id', + field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), + ), + migrations.AlterField( + model_name='user', + name='avatar', + field=bookwyrm.models.fields.ImageField(blank=True, null=True, upload_to='avatars/'), + ), + migrations.AlterField( + model_name='user', + name='bookwyrm_user', + field=bookwyrm.models.fields.BooleanField(default=True), + ), + migrations.AlterField( + model_name='user', + name='inbox', + field=bookwyrm.models.fields.RemoteIdField(max_length=255, unique=True, validators=[bookwyrm.models.fields.validate_remote_id]), + ), + migrations.AlterField( + model_name='user', + name='local', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='user', + name='manually_approves_followers', + field=bookwyrm.models.fields.BooleanField(default=False), + ), + migrations.AlterField( + model_name='user', + name='name', + field=bookwyrm.models.fields.CharField(blank=True, max_length=100, null=True), + ), + migrations.AlterField( + model_name='user', + name='outbox', + field=bookwyrm.models.fields.RemoteIdField(max_length=255, unique=True, validators=[bookwyrm.models.fields.validate_remote_id]), + ), + migrations.AlterField( + model_name='user', + name='remote_id', + field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, unique=True, validators=[bookwyrm.models.fields.validate_remote_id]), + ), + migrations.AlterField( + model_name='user', + name='shared_inbox', + field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), + ), + migrations.AlterField( + model_name='user', + name='summary', + field=bookwyrm.models.fields.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name='user', + name='username', + field=bookwyrm.models.fields.UsernameField(), + ), + migrations.AlterField( + model_name='userblocks', + name='remote_id', + field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), + ), + migrations.AlterField( + model_name='userfollowrequest', + name='remote_id', + field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), + ), + migrations.AlterField( + model_name='userfollows', + name='remote_id', + field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), + ), + migrations.AlterField( + model_name='usertag', + name='remote_id', + field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), + ), + migrations.AddField( + model_name='user', + name='key_pair', + field=bookwyrm.models.fields.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='owner', to='bookwyrm.KeyPair'), + ), + migrations.RunPython(copy_rsa_keys), + ] diff --git a/bookwyrm/models/base_model.py b/bookwyrm/models/base_model.py index f5f244c5..90b889e0 100644 --- a/bookwyrm/models/base_model.py +++ b/bookwyrm/models/base_model.py @@ -17,6 +17,7 @@ from django.dispatch import receiver from bookwyrm import activitypub from bookwyrm.settings import DOMAIN +from .fields import RemoteIdField PrivacyLevels = models.TextChoices('Privacy', [ @@ -30,7 +31,7 @@ class BookWyrmModel(models.Model): ''' shared fields ''' created_date = models.DateTimeField(auto_now_add=True) updated_date = models.DateTimeField(auto_now=True) - remote_id = models.CharField(max_length=255, null=True) + remote_id = RemoteIdField(null=True, activitypub_field='id') def get_remote_id(self): ''' generate a url that resolves to the local object ''' @@ -61,49 +62,18 @@ class ActivitypubMixin: def to_activity(self, pure=False): ''' convert from a model to an activity ''' - if pure: - # works around bookwyrm-specific fields for vanilla AP services - mappings = self.pure_activity_mappings - else: - # may include custom fields that bookwyrm instances will understand - mappings = self.activity_mappings + activity = {} + for field in self.__class__._meta.fields: + key, value = field.to_activity(getattr(self, field.name)) + activity[key] = value + for related_object in self.__class__.meta.related_objects: + # TODO: check if it's serializable + related_model = related_object.related_model + key = related_object.name + related_values = getattr(self, key) + activity[key] = [i.remote_id for i in related_values] - fields = {} - for mapping in mappings: - if not hasattr(self, mapping.model_key) or not mapping.activity_key: - # this field on the model isn't serialized - continue - value = getattr(self, mapping.model_key) - model_field = getattr(self.__class__, mapping.model_key) - if hasattr(value, 'remote_id'): - # this is probably a foreign key field, which we want to - # serialize as just the remote_id url reference - value = value.remote_id - elif isinstance(model_field, \ - (ManyToManyDescriptor, ReverseManyToOneDescriptor)): - value = [i.remote_id for i in value.all()] - elif isinstance(value, datetime): - value = value.isoformat() - elif isinstance(model_field, ImageFileDescriptor): - value = image_formatter(value) - - # run the custom formatter function set in the model - formatted_value = mapping.activity_formatter(value) - if mapping.activity_key in fields and \ - isinstance(fields[mapping.activity_key], list): - # there can be two database fields that map to the same AP list - # this happens in status tags, which combines user and book tags - fields[mapping.activity_key] += formatted_value - else: - fields[mapping.activity_key] = formatted_value - - if pure: - return self.pure_activity_serializer( - **fields - ).serialize() - return self.activity_serializer( - **fields - ).serialize() + return self.activity_serializer(**activity_json).serialize() def to_create_activity(self, user, pure=False): diff --git a/bookwyrm/models/fields.py b/bookwyrm/models/fields.py new file mode 100644 index 00000000..c231039c --- /dev/null +++ b/bookwyrm/models/fields.py @@ -0,0 +1,165 @@ +''' activitypub-aware django model fields ''' +import re +from uuid import uuid4 + +from django.contrib.auth.models import AbstractUser +from django.core.exceptions import ValidationError +from django.core.files.base import ContentFile +from django.db import models +from django.utils.translation import gettext_lazy as _ +from bookwyrm import activitypub +from bookwyrm.settings import DOMAIN +from bookwyrm.connectors import get_image + + +def validate_remote_id(value): + ''' make sure the remote_id looks like a url ''' + if not re.match(r'^http.?:\/\/[^\s]+$', value): + raise ValidationError( + _('%(value)s is not a valid remote_id'), + params={'value': value}, + ) + + +def to_camel_case(snake_string): + ''' model_field_name to activitypubFieldName ''' + components = snake_string.split('_') + return components[0] + ''.join(x.title() for x in components[1:]) + + +class ActivitypubFieldMixin: + ''' make a database field serializable ''' + def __init__(self, *args, \ + activitypub_field=None, activitypub_wrapper=None, **kwargs): + self.activitypub_wrapper = activitypub_wrapper + self.activitypub_field = activitypub_field + super().__init__(*args, **kwargs) + + def to_activity(self, value): + ''' formatter to convert a model value into activitypub ''' + if self.activitypub_wrapper: + value = {self.activitypub_wrapper: value} + return (self.activitypub_field, value) + + def from_activity(self, activity_data): + ''' formatter to convert activitypub into a model value ''' + value = activity_data.get(self.activitypub_field) + if self.activitypub_wrapper: + value = value.get(self.activitypub_wrapper) + return value + + +class RemoteIdField(ActivitypubFieldMixin, models.CharField): + ''' a url that serves as a unique identifier ''' + def __init__(self, *args, max_length=255, validators=None, **kwargs): + validators = validators or [validate_remote_id] + super().__init__( + *args, max_length=max_length, validators=validators, + **kwargs + ) + + +class UsernameField(ActivitypubFieldMixin, models.CharField): + ''' activitypub-aware username field ''' + def __init__(self, activitypub_field='preferredUsername'): + self.activitypub_field = activitypub_field + super(ActivitypubFieldMixin, self).__init__( + _('username'), + max_length=150, + unique=True, + validators=[AbstractUser.username_validator], + error_messages={ + 'unique': _('A user with that username already exists.'), + }, + ) + + def deconstruct(self): + name, path, args, kwargs = super().deconstruct() + del kwargs['verbose_name'] + del kwargs['max_length'] + del kwargs['unique'] + del kwargs['validators'] + del kwargs['error_messages'] + return name, path, args, kwargs + + def to_activity(self, value): + return value.split('@')[0] + + +class ForeignKey(ActivitypubFieldMixin, models.ForeignKey): + ''' activitypub-aware foreign key field ''' + def to_activity(self, value): + return value.remote_id + def from_activity(self, activity_data): + pass# TODO + + +class OneToOneField(ActivitypubFieldMixin, models.OneToOneField): + ''' activitypub-aware foreign key field ''' + def __init__(self, *args, **kwargs): + super(ActivitypubFieldMixin, self).__init__(*args, **kwargs) + + def to_activity(self, value): + return value.remote_id + def from_activity(self, activity_data): + pass# TODO + + +class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField): + ''' activitypub-aware many to many field ''' + def __init__(self, *args, link_only=False, **kwargs): + self.link_only = link_only + super().__init__(*args, **kwargs) + + def to_activity(self, value): + if self.link_only: + return '%s/followers' % self.instance.remote_id + return [i.remote_id for i in value] + + def from_activity(self, activity_data): + if self.link_only: + return + values = super().from_activity(self, activity_data) + return values# TODO + + +class ImageField(ActivitypubFieldMixin, models.ImageField): + ''' activitypub-aware image field ''' + def to_activity(self, value): + if value and hasattr(value, 'url'): + url = value.url + else: + return None + url = 'https://%s%s' % (DOMAIN, url) + return activitypub.Image(url=url) + + def from_activity(self, activity_data): + image_slug = super().from_activity(activity_data) + # when it's an inline image (User avatar/icon, Book cover), it's a json + # blob, but when it's an attached image, it's just a url + if isinstance(image_slug, dict): + url = image_slug.get('url') + elif isinstance(image_slug, str): + url = image_slug + else: + return None + if not url: + return None + + response = get_image(url) + if not response: + return None + + image_name = str(uuid4()) + '.' + url.split('.')[-1] + image_content = ContentFile(response.content) + return [image_name, image_content] + + +class CharField(ActivitypubFieldMixin, models.CharField): + ''' activitypub-aware char field ''' + +class TextField(ActivitypubFieldMixin, models.TextField): + ''' activitypub-aware text field ''' + +class BooleanField(ActivitypubFieldMixin, models.BooleanField): + ''' activitypub-aware boolean field ''' diff --git a/bookwyrm/models/user.py b/bookwyrm/models/user.py index 5b5af77e..19746eb1 100644 --- a/bookwyrm/models/user.py +++ b/bookwyrm/models/user.py @@ -1,6 +1,5 @@ ''' database schema for user data ''' from urllib.parse import urlparse -import requests from django.contrib.auth.models import AbstractUser from django.db import models @@ -13,40 +12,51 @@ from bookwyrm.models.status import Status, Review from bookwyrm.settings import DOMAIN from bookwyrm.signatures import create_key_pair from bookwyrm.tasks import app -from .base_model import ActivityMapping, OrderedCollectionPageMixin +from .base_model import OrderedCollectionPageMixin +from .base_model import ActivitypubMixin, BookWyrmModel from .federated_server import FederatedServer +from . import fields class User(OrderedCollectionPageMixin, AbstractUser): ''' a user who wants to read books ''' - private_key = models.TextField(blank=True, null=True) - public_key = models.TextField(blank=True, null=True) - inbox = models.CharField(max_length=255, unique=True) - shared_inbox = models.CharField(max_length=255, blank=True, null=True) + username = fields.UsernameField() + + key_pair = fields.OneToOneField( + 'KeyPair', + on_delete=models.CASCADE, + blank=True, null=True, + related_name='owner' + ) + inbox = fields.RemoteIdField(unique=True) + shared_inbox = fields.RemoteIdField( + activitypub_wrapper='endpoints', null=True) federated_server = models.ForeignKey( 'FederatedServer', on_delete=models.PROTECT, null=True, blank=True, ) - outbox = models.CharField(max_length=255, unique=True) - summary = models.TextField(blank=True, null=True) - local = models.BooleanField(default=True) - bookwyrm_user = models.BooleanField(default=True) + outbox = fields.RemoteIdField(unique=True) + summary = fields.TextField(blank=True, null=True) + local = models.BooleanField(default=False) + bookwyrm_user = fields.BooleanField(default=True) localname = models.CharField( max_length=255, null=True, unique=True ) # name is your display name, which you can change at will - name = models.CharField(max_length=100, blank=True, null=True) - avatar = models.ImageField(upload_to='avatars/', blank=True, null=True) - following = models.ManyToManyField( + name = fields.CharField(max_length=100, blank=True, null=True) + avatar = fields.ImageField( + upload_to='avatars/', blank=True, null=True, activitypub_field='icon') + followers = fields.ManyToManyField( 'self', + link_only=True, symmetrical=False, through='UserFollows', - through_fields=('user_subject', 'user_object'), - related_name='followers' + through_fields=('user_object', 'user_subject'), + related_name='following' ) follow_requests = models.ManyToManyField( 'self', @@ -69,60 +79,13 @@ class User(OrderedCollectionPageMixin, AbstractUser): through_fields=('user', 'status'), related_name='favorite_statuses' ) - remote_id = models.CharField(max_length=255, null=True, unique=True) + remote_id = fields.RemoteIdField( + null=True, unique=True, activitypub_field='id') created_date = models.DateTimeField(auto_now_add=True) updated_date = models.DateTimeField(auto_now=True) last_active_date = models.DateTimeField(auto_now=True) - manually_approves_followers = models.BooleanField(default=False) + manually_approves_followers = fields.BooleanField(default=False) - # ---- activitypub serialization settings for this model ----- # - @property - def ap_followers(self): - ''' generates url for activitypub followers page ''' - return '%s/followers' % self.remote_id - - @property - def ap_public_key(self): - ''' format the public key block for activitypub ''' - return activitypub.PublicKey(**{ - 'id': '%s/#main-key' % self.remote_id, - 'owner': self.remote_id, - 'publicKeyPem': self.public_key, - }) - - activity_mappings = [ - ActivityMapping('id', 'remote_id'), - ActivityMapping( - 'preferredUsername', - 'username', - activity_formatter=lambda x: x.split('@')[0] - ), - ActivityMapping('name', 'name'), - ActivityMapping('bookwyrmUser', 'bookwyrm_user'), - ActivityMapping('inbox', 'inbox'), - ActivityMapping('outbox', 'outbox'), - ActivityMapping('followers', 'ap_followers'), - ActivityMapping('summary', 'summary'), - ActivityMapping( - 'publicKey', - 'public_key', - model_formatter=lambda x: x.get('publicKeyPem') - ), - ActivityMapping('publicKey', 'ap_public_key'), - ActivityMapping( - 'endpoints', - 'shared_inbox', - activity_formatter=lambda x: {'sharedInbox': x}, - model_formatter=lambda x: x.get('sharedInbox') - ), - ActivityMapping('icon', 'avatar'), - ActivityMapping( - 'manuallyApprovesFollowers', - 'manually_approves_followers' - ), - # this field isn't in the activity but should always be false - ActivityMapping(None, 'local', model_formatter=lambda x: False), - ] activity_serializer = activitypub.Person def to_outbox(self, **kwargs): @@ -183,12 +146,30 @@ class User(OrderedCollectionPageMixin, AbstractUser): self.inbox = '%s/inbox' % self.remote_id self.shared_inbox = 'https://%s/inbox' % DOMAIN self.outbox = '%s/outbox' % self.remote_id - if not self.private_key: - self.private_key, self.public_key = create_key_pair() + if not self.key_pair: + self.key_pair = KeyPair.objects.create() return super().save(*args, **kwargs) +class KeyPair(ActivitypubMixin, BookWyrmModel): + ''' public and private keys for a user ''' + private_key = models.TextField(blank=True, null=True) + public_key = fields.TextField( + blank=True, null=True, activitypub_field='publicKeyPem') + + activity_serializer = activitypub.PublicKey + + def get_remote_id(self): + # self.owner is set by the OneToOneField on User + return '%s/#main-key' % self.owner.remote_id + + def save(self, *args, **kwargs): + ''' create a key pair ''' + self.private_key, self.public_key = create_key_pair() + return super().save(*args, **kwargs) + + @receiver(models.signals.post_save, sender=User) def execute_after_save(sender, instance, created, *args, **kwargs): ''' create shelves for new users ''' @@ -217,6 +198,7 @@ def execute_after_save(sender, instance, created, *args, **kwargs): editable=False ).save() + @app.task def set_remote_server(user_id): ''' figure out the user's remote server in the background ''' @@ -227,7 +209,6 @@ def set_remote_server(user_id): user.save() if user.bookwyrm_user: get_remote_reviews.delay(user.outbox) - return def get_or_create_remote_server(domain): diff --git a/bookwyrm/view_actions.py b/bookwyrm/view_actions.py index 29dc6406..cf0e7644 100644 --- a/bookwyrm/view_actions.py +++ b/bookwyrm/view_actions.py @@ -84,7 +84,8 @@ def register(request): } return TemplateResponse(request, 'login.html', data) - user = models.User.objects.create_user(username, email, password) + user = models.User.objects.create_user( + username, email, password, local=True) if invite: invite.times_used += 1 invite.save()