Compare commits

...

4 commits

Author SHA1 Message Date
Mouse Reeve
c9dc1437a1 Removes debug print statements 2021-10-27 10:26:55 -07:00
Mouse Reeve
94ce426617 Fixes how inboxes are initialized and updates user model tests 2021-10-27 10:25:51 -07:00
Mouse Reeve
b01a64d560 Adds keypair model location to init 2021-10-25 18:48:24 -07:00
Mouse Reeve
bda7d40f8c Move actor model attrs into their own abstract class 2021-10-25 18:42:57 -07:00
4 changed files with 196 additions and 160 deletions

View file

@ -16,7 +16,8 @@ from .attachment import Image
from .favorite import Favorite from .favorite import Favorite
from .readthrough import ReadThrough, ProgressUpdate, ProgressMode from .readthrough import ReadThrough, ProgressUpdate, ProgressMode
from .user import User, KeyPair, AnnualGoal from .actor import KeyPair
from .user import User, AnnualGoal
from .relationship import UserFollows, UserFollowRequest, UserBlocks from .relationship import UserFollows, UserFollowRequest, UserBlocks
from .report import Report, ReportComment from .report import Report, ReportComment
from .federated_server import FederatedServer from .federated_server import FederatedServer

170
bookwyrm/models/actor.py Normal file
View file

@ -0,0 +1,170 @@
""" base model for actors with default fields """
from urllib.parse import urlparse
from django.apps import apps
from django.db import models, transaction
from bookwyrm import activitypub
from bookwyrm.connectors import get_data, ConnectorException
from bookwyrm.signatures import create_key_pair
from bookwyrm.tasks import app
from .activitypub_mixin import ActivitypubMixin
from .base_model import BookWyrmModel
from .federated_server import FederatedServer
from . import fields
class ActorModel(BookWyrmModel):
"""something that posts"""
remote_id = fields.RemoteIdField(null=True, activitypub_field="id", unique=True)
summary = fields.HtmlField(null=True, blank=True)
outbox = fields.RemoteIdField(unique=True, null=True)
followers_url = fields.CharField(max_length=255, activitypub_field="followers")
federated_server = models.ForeignKey(
"FederatedServer",
on_delete=models.PROTECT,
null=True,
blank=True,
)
inbox = fields.RemoteIdField(unique=True)
local = models.BooleanField(default=False)
discoverable = fields.BooleanField(default=False)
default_post_privacy = models.CharField(
max_length=255, default="public", choices=fields.PrivacyLevels.choices
)
manually_approves_followers = fields.BooleanField(default=False)
key_pair = fields.OneToOneField(
"KeyPair",
on_delete=models.CASCADE,
blank=True,
null=True,
activitypub_field="publicKey",
related_name="owner",
)
property_fields = [("following_link", "following")]
def save(self, *args, **kwargs):
"""set fields"""
created = not bool(self.id)
if not created:
super().save(*args, **kwargs)
return
with transaction.atomic():
# this is a new remote obj, we need to set their remote server field
if not self.local:
super().save(*args, **kwargs)
transaction.on_commit(lambda: set_remote_server.delay(self.id))
return
self.followers_url = f"{self.remote_id}/followers"
self.inbox = f"{self.remote_id}/inbox"
self.outbox = f"{self.remote_id}/outbox"
super().save(*args, **kwargs)
# create keys and shelves for new local users
self.key_pair = KeyPair.objects.create(
remote_id=f"{self.remote_id}/#main-key"
)
self.save(broadcast=False, update_fields=["key_pair"])
@classmethod
def viewer_aware_objects(cls, viewer):
"""the user queryset filtered for the context of the logged in user"""
queryset = cls.objects.filter(is_active=True)
if viewer and viewer.is_authenticated:
queryset = queryset.exclude(blocks=viewer)
return queryset
class Meta:
"""this is just here to provide default fields for other models"""
abstract = True
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
serialize_reverse_fields = [("owner", "owner", "id")]
def get_remote_id(self):
# self.owner is set by the OneToOneField on User
return f"{self.owner.remote_id}/#main-key"
def save(self, *args, **kwargs):
"""create a key pair"""
# no broadcasting happening here
if "broadcast" in kwargs:
del kwargs["broadcast"]
if not self.public_key:
self.private_key, self.public_key = create_key_pair()
return super().save(*args, **kwargs)
def to_activity(self, **kwargs):
"""override default AP serializer to add context object
idk if this is the best way to go about this"""
activity_object = super().to_activity(**kwargs)
del activity_object["@context"]
del activity_object["type"]
return activity_object
@app.task(queue="low_priority")
def set_remote_server(user_id):
"""figure out the user's remote server in the background"""
model = apps.get_model("bookwyrm.User", require_ready=True)
user = model.objects.get(id=user_id)
actor_parts = urlparse(user.remote_id)
user.federated_server = get_or_create_remote_server(actor_parts.netloc)
user.save(broadcast=False, update_fields=["federated_server"])
if user.bookwyrm_user and user.outbox:
get_remote_reviews.delay(user.outbox)
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
try:
data = get_data(f"https://{domain}/.well-known/nodeinfo")
try:
nodeinfo_url = data.get("links")[0].get("href")
except (TypeError, KeyError):
raise ConnectorException()
data = get_data(nodeinfo_url)
application_type = data.get("software", {}).get("name")
application_version = data.get("software", {}).get("version")
except ConnectorException:
application_type = application_version = None
server = FederatedServer.objects.create(
server_name=domain,
application_type=application_type,
application_version=application_version,
)
return server
@app.task(queue="low_priority")
def get_remote_reviews(outbox):
"""ingest reviews by a new remote bookwyrm user"""
outbox_page = outbox + "?page=true&type=Review"
data = get_data(outbox_page)
# TODO: pagination?
for activity in data["orderedItems"]:
if not activity["type"] == "Review":
continue
activitypub.Review(**activity).to_model()

View file

@ -1,6 +1,6 @@
""" database schema for user data """ """ database schema for user data """
import re
from urllib.parse import urlparse from urllib.parse import urlparse
import re
from django.apps import apps from django.apps import apps
from django.contrib.auth.models import AbstractUser, Group from django.contrib.auth.models import AbstractUser, Group
@ -13,18 +13,15 @@ from model_utils import FieldTracker
import pytz import pytz
from bookwyrm import activitypub from bookwyrm import activitypub
from bookwyrm.connectors import get_data, ConnectorException
from bookwyrm.models.shelf import Shelf from bookwyrm.models.shelf import Shelf
from bookwyrm.models.status import Status, Review from bookwyrm.models.status import Status, Review
from bookwyrm.preview_images import generate_user_preview_image_task from bookwyrm.preview_images import generate_user_preview_image_task
from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES, USE_HTTPS, LANGUAGES from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES, USE_HTTPS, LANGUAGES
from bookwyrm.signatures import create_key_pair
from bookwyrm.tasks import app
from bookwyrm.utils import regex from bookwyrm.utils import regex
from .activitypub_mixin import OrderedCollectionPageMixin, ActivitypubMixin from .activitypub_mixin import OrderedCollectionPageMixin
from .base_model import BookWyrmModel, DeactivationReason, new_access_code from .base_model import BookWyrmModel, DeactivationReason, new_access_code
from .federated_server import FederatedServer from .actor import ActorModel
from . import fields, Review from . import fields
def site_link(): def site_link():
@ -33,36 +30,12 @@ def site_link():
return f"{protocol}://{DOMAIN}" return f"{protocol}://{DOMAIN}"
class User(OrderedCollectionPageMixin, AbstractUser): class User(OrderedCollectionPageMixin, AbstractUser, ActorModel):
"""a user who wants to read books""" """a user who wants to read books"""
username = fields.UsernameField() username = fields.UsernameField()
email = models.EmailField(unique=True, null=True) email = models.EmailField(unique=True, null=True)
key_pair = fields.OneToOneField(
"KeyPair",
on_delete=models.CASCADE,
blank=True,
null=True,
activitypub_field="publicKey",
related_name="owner",
)
inbox = fields.RemoteIdField(unique=True)
shared_inbox = fields.RemoteIdField(
activitypub_field="sharedInbox",
activitypub_wrapper="endpoints",
deduplication_field=False,
null=True,
)
federated_server = models.ForeignKey(
"FederatedServer",
on_delete=models.PROTECT,
null=True,
blank=True,
)
outbox = fields.RemoteIdField(unique=True, null=True)
summary = fields.HtmlField(null=True, blank=True)
local = models.BooleanField(default=False)
bookwyrm_user = fields.BooleanField(default=True) bookwyrm_user = fields.BooleanField(default=True)
localname = CICharField( localname = CICharField(
max_length=255, max_length=255,
@ -70,6 +43,12 @@ class User(OrderedCollectionPageMixin, AbstractUser):
unique=True, unique=True,
validators=[fields.validate_localname], validators=[fields.validate_localname],
) )
shared_inbox = fields.RemoteIdField(
activitypub_field="sharedInbox",
activitypub_wrapper="endpoints",
deduplication_field=False,
null=True,
)
# name is your display name, which you can change at will # name is your display name, which you can change at will
name = fields.CharField(max_length=100, null=True, blank=True) name = fields.CharField(max_length=100, null=True, blank=True)
avatar = fields.ImageField( avatar = fields.ImageField(
@ -114,19 +93,14 @@ class User(OrderedCollectionPageMixin, AbstractUser):
through_fields=("user", "status"), through_fields=("user", "status"),
related_name="favorite_statuses", related_name="favorite_statuses",
) )
default_post_privacy = models.CharField(
max_length=255, default="public", choices=fields.PrivacyLevels.choices
)
remote_id = fields.RemoteIdField(null=True, unique=True, activitypub_field="id") remote_id = fields.RemoteIdField(null=True, unique=True, activitypub_field="id")
created_date = models.DateTimeField(auto_now_add=True) created_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True) updated_date = models.DateTimeField(auto_now=True)
last_active_date = models.DateTimeField(default=timezone.now) last_active_date = models.DateTimeField(default=timezone.now)
manually_approves_followers = fields.BooleanField(default=False)
# options to turn features on and off # options to turn features on and off
show_goal = models.BooleanField(default=True) show_goal = models.BooleanField(default=True)
show_suggested_users = models.BooleanField(default=True) show_suggested_users = models.BooleanField(default=True)
discoverable = fields.BooleanField(default=False)
preferred_timezone = models.CharField( preferred_timezone = models.CharField(
choices=[(str(tz), str(tz)) for tz in pytz.all_timezones], choices=[(str(tz), str(tz)) for tz in pytz.all_timezones],
@ -146,7 +120,6 @@ class User(OrderedCollectionPageMixin, AbstractUser):
confirmation_code = models.CharField(max_length=32, default=new_access_code) confirmation_code = models.CharField(max_length=32, default=new_access_code)
name_field = "username" name_field = "username"
property_fields = [("following_link", "following")]
field_tracker = FieldTracker(fields=["name", "avatar"]) field_tracker = FieldTracker(fields=["name", "avatar"])
@property @property
@ -193,14 +166,6 @@ class User(OrderedCollectionPageMixin, AbstractUser):
activity_serializer = activitypub.Person activity_serializer = activitypub.Person
@classmethod
def viewer_aware_objects(cls, viewer):
"""the user queryset filtered for the context of the logged in user"""
queryset = cls.objects.filter(is_active=True)
if viewer and viewer.is_authenticated:
queryset = queryset.exclude(blocks=viewer)
return queryset
def update_active_date(self): def update_active_date(self):
"""this user is here! they are doing things!""" """this user is here! they are doing things!"""
self.last_active_date = timezone.now() self.last_active_date = timezone.now()
@ -274,12 +239,13 @@ class User(OrderedCollectionPageMixin, AbstractUser):
"""populate fields for new local users""" """populate fields for new local users"""
created = not bool(self.id) created = not bool(self.id)
if not self.local and not re.match(regex.FULL_USERNAME, self.username): if not self.local and not re.match(regex.FULL_USERNAME, self.username):
# generate a username that uses the domain (webfinger format) # parse out the username that uses the domain (webfinger format)
actor_parts = urlparse(self.remote_id) actor_parts = urlparse(self.remote_id)
self.username = f"{self.username}@{actor_parts.netloc}" self.username = f"{self.username}@{actor_parts.netloc}"
# this user already exists, no need to populate fields # this user already exists, no need to populate fields
if not created: if not created:
# make sure the deactivation state is correct in case it was updated
if self.is_active: if self.is_active:
self.deactivation_date = None self.deactivation_date = None
elif not self.deactivation_date: elif not self.deactivation_date:
@ -288,21 +254,11 @@ class User(OrderedCollectionPageMixin, AbstractUser):
super().save(*args, **kwargs) super().save(*args, **kwargs)
return return
# this is a new remote user, we need to set their remote server field
if not self.local:
super().save(*args, **kwargs)
transaction.on_commit(lambda: set_remote_server.delay(self.id))
return
with transaction.atomic(): with transaction.atomic():
# populate fields for local users # populate fields for local users
link = site_link() link = site_link()
self.remote_id = f"{link}/user/{self.localname}" self.remote_id = f"{link}/user/{self.localname}"
self.followers_url = f"{self.remote_id}/followers"
self.inbox = f"{self.remote_id}/inbox"
self.shared_inbox = f"{link}/inbox" self.shared_inbox = f"{link}/inbox"
self.outbox = f"{self.remote_id}/outbox"
# an id needs to be set before we can proceed with related models # an id needs to be set before we can proceed with related models
super().save(*args, **kwargs) super().save(*args, **kwargs)
@ -313,12 +269,6 @@ class User(OrderedCollectionPageMixin, AbstractUser):
# this should only happen in tests # this should only happen in tests
pass pass
# create keys and shelves for new local users
self.key_pair = KeyPair.objects.create(
remote_id=f"{self.remote_id}/#main-key"
)
self.save(broadcast=False, update_fields=["key_pair"])
self.create_shelves() self.create_shelves()
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
@ -359,39 +309,6 @@ class User(OrderedCollectionPageMixin, AbstractUser):
).save(broadcast=False) ).save(broadcast=False)
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
serialize_reverse_fields = [("owner", "owner", "id")]
def get_remote_id(self):
# self.owner is set by the OneToOneField on User
return f"{self.owner.remote_id}/#main-key"
def save(self, *args, **kwargs):
"""create a key pair"""
# no broadcasting happening here
if "broadcast" in kwargs:
del kwargs["broadcast"]
if not self.public_key:
self.private_key, self.public_key = create_key_pair()
return super().save(*args, **kwargs)
def to_activity(self, **kwargs):
"""override default AP serializer to add context object
idk if this is the best way to go about this"""
activity_object = super().to_activity(**kwargs)
del activity_object["@context"]
del activity_object["type"]
return activity_object
class AnnualGoal(BookWyrmModel): class AnnualGoal(BookWyrmModel):
"""set a goal for how many books you read in a year""" """set a goal for how many books you read in a year"""
@ -446,58 +363,6 @@ class AnnualGoal(BookWyrmModel):
} }
@app.task(queue="low_priority")
def set_remote_server(user_id):
"""figure out the user's remote server in the background"""
user = User.objects.get(id=user_id)
actor_parts = urlparse(user.remote_id)
user.federated_server = get_or_create_remote_server(actor_parts.netloc)
user.save(broadcast=False, update_fields=["federated_server"])
if user.bookwyrm_user and user.outbox:
get_remote_reviews.delay(user.outbox)
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
try:
data = get_data(f"https://{domain}/.well-known/nodeinfo")
try:
nodeinfo_url = data.get("links")[0].get("href")
except (TypeError, KeyError):
raise ConnectorException()
data = get_data(nodeinfo_url)
application_type = data.get("software", {}).get("name")
application_version = data.get("software", {}).get("version")
except ConnectorException:
application_type = application_version = None
server = FederatedServer.objects.create(
server_name=domain,
application_type=application_type,
application_version=application_version,
)
return server
@app.task(queue="low_priority")
def get_remote_reviews(outbox):
"""ingest reviews by a new remote bookwyrm user"""
outbox_page = outbox + "?page=true&type=Review"
data = get_data(outbox_page)
# TODO: pagination?
for activity in data["orderedItems"]:
if not activity["type"] == "Review":
continue
activitypub.Review(**activity).to_model()
# pylint: disable=unused-argument # pylint: disable=unused-argument
@receiver(models.signals.post_save, sender=User) @receiver(models.signals.post_save, sender=User)
def preview_image(instance, *args, **kwargs): def preview_image(instance, *args, **kwargs):

View file

@ -17,7 +17,7 @@ class User(TestCase):
"bookwyrm.activitystreams.populate_stream_task.delay" "bookwyrm.activitystreams.populate_stream_task.delay"
): ):
self.user = models.User.objects.create_user( self.user = models.User.objects.create_user(
"mouse@%s" % DOMAIN, f"mouse@{DOMAIN}",
"mouse@mouse.mouse", "mouse@mouse.mouse",
"mouseword", "mouseword",
local=True, local=True,
@ -40,7 +40,7 @@ class User(TestCase):
self.assertIsNotNone(self.user.key_pair.public_key) self.assertIsNotNone(self.user.key_pair.public_key)
def test_remote_user(self): def test_remote_user(self):
with patch("bookwyrm.models.user.set_remote_server.delay"): with patch("bookwyrm.models.actor.set_remote_server.delay"):
user = models.User.objects.create_user( user = models.User.objects.create_user(
"rat", "rat",
"rat@rat.rat", "rat@rat.rat",
@ -98,7 +98,7 @@ class User(TestCase):
server_name=DOMAIN, application_type="test type", application_version=3 server_name=DOMAIN, application_type="test type", application_version=3
) )
models.user.set_remote_server(self.user.id) models.actor.set_remote_server(self.user.id)
self.user.refresh_from_db() self.user.refresh_from_db()
self.assertEqual(self.user.federated_server, server) self.assertEqual(self.user.federated_server, server)
@ -107,7 +107,7 @@ class User(TestCase):
def test_get_or_create_remote_server(self): def test_get_or_create_remote_server(self):
responses.add( responses.add(
responses.GET, responses.GET,
"https://%s/.well-known/nodeinfo" % DOMAIN, f"https://{DOMAIN}/.well-known/nodeinfo",
json={"links": [{"href": "http://www.example.com"}, {}]}, json={"links": [{"href": "http://www.example.com"}, {}]},
) )
responses.add( responses.add(
@ -116,7 +116,7 @@ class User(TestCase):
json={"software": {"name": "hi", "version": "2"}}, json={"software": {"name": "hi", "version": "2"}},
) )
server = models.user.get_or_create_remote_server(DOMAIN) server = models.actor.get_or_create_remote_server(DOMAIN)
self.assertEqual(server.server_name, DOMAIN) self.assertEqual(server.server_name, DOMAIN)
self.assertEqual(server.application_type, "hi") self.assertEqual(server.application_type, "hi")
self.assertEqual(server.application_version, "2") self.assertEqual(server.application_version, "2")
@ -124,10 +124,10 @@ class User(TestCase):
@responses.activate @responses.activate
def test_get_or_create_remote_server_no_wellknown(self): def test_get_or_create_remote_server_no_wellknown(self):
responses.add( responses.add(
responses.GET, "https://%s/.well-known/nodeinfo" % DOMAIN, status=404 responses.GET, f"https://{DOMAIN}/.well-known/nodeinfo", status=404
) )
server = models.user.get_or_create_remote_server(DOMAIN) server = models.actor.get_or_create_remote_server(DOMAIN)
self.assertEqual(server.server_name, DOMAIN) self.assertEqual(server.server_name, DOMAIN)
self.assertIsNone(server.application_type) self.assertIsNone(server.application_type)
self.assertIsNone(server.application_version) self.assertIsNone(server.application_version)
@ -136,12 +136,12 @@ class User(TestCase):
def test_get_or_create_remote_server_no_links(self): def test_get_or_create_remote_server_no_links(self):
responses.add( responses.add(
responses.GET, responses.GET,
"https://%s/.well-known/nodeinfo" % DOMAIN, f"https://{DOMAIN}/.well-known/nodeinfo",
json={"links": [{"href": "http://www.example.com"}, {}]}, json={"links": [{"href": "http://www.example.com"}, {}]},
) )
responses.add(responses.GET, "http://www.example.com", status=404) responses.add(responses.GET, "http://www.example.com", status=404)
server = models.user.get_or_create_remote_server(DOMAIN) server = models.actor.get_or_create_remote_server(DOMAIN)
self.assertEqual(server.server_name, DOMAIN) self.assertEqual(server.server_name, DOMAIN)
self.assertIsNone(server.application_type) self.assertIsNone(server.application_type)
self.assertIsNone(server.application_version) self.assertIsNone(server.application_version)
@ -150,12 +150,12 @@ class User(TestCase):
def test_get_or_create_remote_server_unknown_format(self): def test_get_or_create_remote_server_unknown_format(self):
responses.add( responses.add(
responses.GET, responses.GET,
"https://%s/.well-known/nodeinfo" % DOMAIN, f"https://{DOMAIN}/.well-known/nodeinfo",
json={"links": [{"href": "http://www.example.com"}, {}]}, json={"links": [{"href": "http://www.example.com"}, {}]},
) )
responses.add(responses.GET, "http://www.example.com", json={"fish": "salmon"}) responses.add(responses.GET, "http://www.example.com", json={"fish": "salmon"})
server = models.user.get_or_create_remote_server(DOMAIN) server = models.actor.get_or_create_remote_server(DOMAIN)
self.assertEqual(server.server_name, DOMAIN) self.assertEqual(server.server_name, DOMAIN)
self.assertIsNone(server.application_type) self.assertIsNone(server.application_type)
self.assertIsNone(server.application_version) self.assertIsNone(server.application_version)