bookwyrm/bookwyrm/models/user.py
Mouse Reeve b0202eb8e8 Remove special remote user handling code
also fixes date parsing
2020-11-28 11:48:17 -08:00

277 lines
9.1 KiB
Python

''' database schema for user data '''
from urllib.parse import urlparse
import requests
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.dispatch import receiver
from bookwyrm import activitypub
from bookwyrm.models.shelf import Shelf
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 .federated_server import FederatedServer
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)
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)
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(
'self',
symmetrical=False,
through='UserFollows',
through_fields=('user_subject', 'user_object'),
related_name='followers'
)
follow_requests = models.ManyToManyField(
'self',
symmetrical=False,
through='UserFollowRequest',
through_fields=('user_subject', 'user_object'),
related_name='follower_requests'
)
blocks = models.ManyToManyField(
'self',
symmetrical=False,
through='UserBlocks',
through_fields=('user_subject', 'user_object'),
related_name='blocked_by'
)
favorites = models.ManyToManyField(
'Status',
symmetrical=False,
through='Favorite',
through_fields=('user', 'status'),
related_name='favorite_statuses'
)
remote_id = models.CharField(max_length=255, null=True, unique=True)
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)
# ---- 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):
''' an ordered collection of statuses '''
queryset = Status.objects.filter(
user=self,
deleted=False,
).select_subclasses()
return self.to_ordered_collection(queryset, \
remote_id=self.outbox, **kwargs)
def to_following_activity(self, **kwargs):
''' activitypub following list '''
remote_id = '%s/following' % self.remote_id
return self.to_ordered_collection(self.following, \
remote_id=remote_id, id_only=True, **kwargs)
def to_followers_activity(self, **kwargs):
''' activitypub followers list '''
remote_id = '%s/followers' % self.remote_id
return self.to_ordered_collection(self.followers, \
remote_id=remote_id, id_only=True, **kwargs)
def to_activity(self, pure=False):
''' override default AP serializer to add context object
idk if this is the best way to go about this '''
activity_object = super().to_activity()
activity_object['@context'] = [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
{
'manuallyApprovesFollowers': 'as:manuallyApprovesFollowers',
'schema': 'http://schema.org#',
'PropertyValue': 'schema:PropertyValue',
'value': 'schema:value',
}
]
return activity_object
def save(self, *args, **kwargs):
''' populate fields for new local users '''
# this user already exists, no need to populate fields
if self.id:
return super().save(*args, **kwargs)
if not self.local:
# generate a username that uses the domain (webfinger format)
actor_parts = urlparse(self.remote_id)
self.username = '%s@%s' % (self.username, actor_parts.netloc)
return super().save(*args, **kwargs)
# populate fields for local users
self.remote_id = 'https://%s/user/%s' % (DOMAIN, self.username)
self.localname = self.username
self.username = '%s@%s' % (self.username, DOMAIN)
self.actor = self.remote_id
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()
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 '''
if not created:
return
if not instance.local:
actor_parts = urlparse(instance.remote_id)
instance.federated_server = \
get_or_create_remote_server(actor_parts.netloc)
instance.save()
if instance.bookwyrm_user:
get_remote_reviews.delay(instance.outbox)
return
shelves = [{
'name': 'To Read',
'identifier': 'to-read',
}, {
'name': 'Currently Reading',
'identifier': 'reading',
}, {
'name': 'Read',
'identifier': 'read',
}]
for shelf in shelves:
Shelf(
name=shelf['name'],
identifier=shelf['identifier'],
user=instance,
editable=False
).save()
def get_or_create_remote_server(domain):
''' get info on a remote server '''
try:
return FederatedServer.objects.get(
server_name=domain
)
except FederatedServer.DoesNotExist:
pass
response = requests.get(
'https://%s/.well-known/nodeinfo' % domain,
headers={'Accept': 'application/activity+json'}
)
if response.status_code != 200:
return None
data = response.json()
try:
nodeinfo_url = data.get('links')[0].get('href')
except (TypeError, KeyError):
return None
response = requests.get(
nodeinfo_url,
headers={'Accept': 'application/activity+json'}
)
data = response.json()
server = FederatedServer.objects.create(
server_name=domain,
application_type=data['software']['name'],
application_version=data['software']['version'],
)
return server
@app.task
def get_remote_reviews(outbox):
''' ingest reviews by a new remote bookwyrm user '''
outbox_page = outbox + '?page=true'
response = requests.get(
outbox_page,
headers={'Accept': 'application/activity+json'}
)
data = response.json()
# TODO: pagination?
for activity in data['orderedItems']:
if not activity['type'] == 'Review':
continue
activitypub.Review(**activity).to_model(Review)