Merge branch 'main' into remove-tags
This commit is contained in:
commit
563623616c
158 changed files with 5668 additions and 2066 deletions
|
@ -148,13 +148,17 @@ class ActivitypubMixin:
|
|||
mentions = self.recipients if hasattr(self, "recipients") else []
|
||||
|
||||
# we always send activities to explicitly mentioned users' inboxes
|
||||
recipients = [u.inbox for u in mentions or []]
|
||||
recipients = [u.inbox for u in mentions or [] if not u.local]
|
||||
|
||||
# unless it's a dm, all the followers should receive the activity
|
||||
if privacy != "direct":
|
||||
# we will send this out to a subset of all remote users
|
||||
queryset = user_model.objects.filter(
|
||||
local=False,
|
||||
queryset = (
|
||||
user_model.viewer_aware_objects(user)
|
||||
.filter(
|
||||
local=False,
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
# filter users first by whether they're using the desired software
|
||||
# this lets us send book updates only to other bw servers
|
||||
|
@ -175,7 +179,7 @@ class ActivitypubMixin:
|
|||
"inbox", flat=True
|
||||
)
|
||||
recipients += list(shared_inboxes) + list(inboxes)
|
||||
return recipients
|
||||
return list(set(recipients))
|
||||
|
||||
def to_activity_dataclass(self):
|
||||
""" convert from a model to an activity """
|
||||
|
@ -193,7 +197,7 @@ class ObjectMixin(ActivitypubMixin):
|
|||
def save(self, *args, created=None, **kwargs):
|
||||
""" broadcast created/updated/deleted objects as appropriate """
|
||||
broadcast = kwargs.get("broadcast", True)
|
||||
# this bonus kwarg woul cause an error in the base save method
|
||||
# this bonus kwarg would cause an error in the base save method
|
||||
if "broadcast" in kwargs:
|
||||
del kwargs["broadcast"]
|
||||
|
||||
|
@ -359,6 +363,10 @@ class CollectionItemMixin(ActivitypubMixin):
|
|||
|
||||
activity_serializer = activitypub.CollectionItem
|
||||
|
||||
def broadcast(self, activity, sender, software="bookwyrm"):
|
||||
""" only send book collection updates to other bookwyrm instances """
|
||||
super().broadcast(activity, sender, software=software)
|
||||
|
||||
@property
|
||||
def privacy(self):
|
||||
""" inherit the privacy of the list, or direct if pending """
|
||||
|
@ -371,6 +379,9 @@ class CollectionItemMixin(ActivitypubMixin):
|
|||
def recipients(self):
|
||||
""" the owner of the list is a direct recipient """
|
||||
collection_field = getattr(self, self.collection_field)
|
||||
if collection_field.user.local:
|
||||
# don't broadcast to yourself
|
||||
return []
|
||||
return [collection_field.user]
|
||||
|
||||
def save(self, *args, broadcast=True, **kwargs):
|
||||
|
@ -386,11 +397,11 @@ class CollectionItemMixin(ActivitypubMixin):
|
|||
activity = self.to_add_activity(self.user)
|
||||
self.broadcast(activity, self.user)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
def delete(self, *args, broadcast=True, **kwargs):
|
||||
""" broadcast a remove activity """
|
||||
activity = self.to_remove_activity(self.user)
|
||||
super().delete(*args, **kwargs)
|
||||
if self.user.local:
|
||||
if self.user.local and broadcast:
|
||||
self.broadcast(activity, self.user)
|
||||
|
||||
def to_add_activity(self, user):
|
||||
|
@ -524,7 +535,7 @@ def to_ordered_collection_page(
|
|||
""" serialize and pagiante a queryset """
|
||||
paginated = Paginator(queryset, PAGE_LENGTH)
|
||||
|
||||
activity_page = paginated.page(page)
|
||||
activity_page = paginated.get_page(page)
|
||||
if id_only:
|
||||
items = [s.remote_id for s in activity_page.object_list]
|
||||
else:
|
||||
|
|
|
@ -33,4 +33,4 @@ class Image(Attachment):
|
|||
)
|
||||
caption = fields.TextField(null=True, blank=True, activitypub_field="name")
|
||||
|
||||
activity_serializer = activitypub.Image
|
||||
activity_serializer = activitypub.Document
|
||||
|
|
|
@ -31,6 +31,36 @@ class BookWyrmModel(models.Model):
|
|||
""" how to link to this object in the local app """
|
||||
return self.get_remote_id().replace("https://%s" % DOMAIN, "")
|
||||
|
||||
def visible_to_user(self, viewer):
|
||||
""" is a user authorized to view an object? """
|
||||
# make sure this is an object with privacy owned by a user
|
||||
if not hasattr(self, "user") or not hasattr(self, "privacy"):
|
||||
return None
|
||||
|
||||
# viewer can't see it if the object's owner blocked them
|
||||
if viewer in self.user.blocks.all():
|
||||
return False
|
||||
|
||||
# you can see your own posts and any public or unlisted posts
|
||||
if viewer == self.user or self.privacy in ["public", "unlisted"]:
|
||||
return True
|
||||
|
||||
# you can see the followers only posts of people you follow
|
||||
if (
|
||||
self.privacy == "followers"
|
||||
and self.user.followers.filter(id=viewer.id).first()
|
||||
):
|
||||
return True
|
||||
|
||||
# you can see dms you are tagged in
|
||||
if hasattr(self, "mention_users"):
|
||||
if (
|
||||
self.privacy == "direct"
|
||||
and self.mention_users.filter(id=viewer.id).first()
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@receiver(models.signals.post_save)
|
||||
# pylint: disable=unused-argument
|
||||
|
|
|
@ -26,7 +26,11 @@ class BookDataModel(ObjectMixin, BookWyrmModel):
|
|||
max_length=255, blank=True, null=True, deduplication_field=True
|
||||
)
|
||||
|
||||
last_edited_by = models.ForeignKey("User", on_delete=models.PROTECT, null=True)
|
||||
last_edited_by = fields.ForeignKey(
|
||||
"User",
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
""" can't initialize this model, that wouldn't make sense """
|
||||
|
|
|
@ -1,17 +1,51 @@
|
|||
""" connections to external ActivityPub servers """
|
||||
from urllib.parse import urlparse
|
||||
from django.db import models
|
||||
from .base_model import BookWyrmModel
|
||||
|
||||
FederationStatus = models.TextChoices(
|
||||
"Status",
|
||||
[
|
||||
"federated",
|
||||
"blocked",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class FederatedServer(BookWyrmModel):
|
||||
""" store which servers we federate with """
|
||||
|
||||
server_name = models.CharField(max_length=255, unique=True)
|
||||
# federated, blocked, whatever else
|
||||
status = models.CharField(max_length=255, default="federated")
|
||||
status = models.CharField(
|
||||
max_length=255, default="federated", choices=FederationStatus.choices
|
||||
)
|
||||
# is it mastodon, bookwyrm, etc
|
||||
application_type = models.CharField(max_length=255, null=True)
|
||||
application_version = models.CharField(max_length=255, null=True)
|
||||
application_type = models.CharField(max_length=255, null=True, blank=True)
|
||||
application_version = models.CharField(max_length=255, null=True, blank=True)
|
||||
notes = models.TextField(null=True, blank=True)
|
||||
|
||||
def block(self):
|
||||
""" block a server """
|
||||
self.status = "blocked"
|
||||
self.save()
|
||||
|
||||
# TODO: blocked servers
|
||||
# deactivate all associated users
|
||||
self.user_set.filter(is_active=True).update(
|
||||
is_active=False, deactivation_reason="domain_block"
|
||||
)
|
||||
|
||||
def unblock(self):
|
||||
""" unblock a server """
|
||||
self.status = "federated"
|
||||
self.save()
|
||||
|
||||
self.user_set.filter(deactivation_reason="domain_block").update(
|
||||
is_active=True, deactivation_reason=None
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def is_blocked(cls, url):
|
||||
""" look up if a domain is blocked """
|
||||
url = urlparse(url)
|
||||
domain = url.netloc
|
||||
return cls.objects.filter(server_name=domain, status="blocked").exists()
|
||||
|
|
|
@ -275,9 +275,12 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
|
|||
return [i.remote_id for i in value.all()]
|
||||
|
||||
def field_from_activity(self, value):
|
||||
items = []
|
||||
if value is None or value is MISSING:
|
||||
return []
|
||||
return None
|
||||
if not isinstance(value, list):
|
||||
# If this is a link, we currently aren't doing anything with it
|
||||
return None
|
||||
items = []
|
||||
for remote_id in value:
|
||||
try:
|
||||
validate_remote_id(remote_id)
|
||||
|
@ -336,7 +339,7 @@ def image_serializer(value, alt):
|
|||
else:
|
||||
return None
|
||||
url = "https://%s%s" % (DOMAIN, url)
|
||||
return activitypub.Image(url=url, name=alt)
|
||||
return activitypub.Document(url=url, name=alt)
|
||||
|
||||
|
||||
class ImageField(ActivitypubFieldMixin, models.ImageField):
|
||||
|
|
|
@ -47,7 +47,7 @@ class List(OrderedCollectionMixin, BookWyrmModel):
|
|||
@property
|
||||
def collection_queryset(self):
|
||||
""" list of books for this shelf, overrides OrderedCollectionMixin """
|
||||
return self.books.filter(listitem__approved=True).all().order_by("listitem")
|
||||
return self.books.filter(listitem__approved=True).order_by("listitem")
|
||||
|
||||
class Meta:
|
||||
""" default sorting """
|
||||
|
@ -67,7 +67,7 @@ class ListItem(CollectionItemMixin, BookWyrmModel):
|
|||
)
|
||||
notes = fields.TextField(blank=True, null=True)
|
||||
approved = models.BooleanField(default=True)
|
||||
order = fields.IntegerField(blank=True, null=True)
|
||||
order = fields.IntegerField()
|
||||
endorsement = models.ManyToManyField("User", related_name="endorsers")
|
||||
|
||||
activity_serializer = activitypub.ListItem
|
||||
|
@ -93,7 +93,7 @@ class ListItem(CollectionItemMixin, BookWyrmModel):
|
|||
)
|
||||
|
||||
class Meta:
|
||||
""" an opinionated constraint! you can't put a book on a list twice """
|
||||
|
||||
unique_together = ("book", "book_list")
|
||||
# A book may only be placed into a list once, and each order in the list may be used only
|
||||
# once
|
||||
unique_together = (("book", "book_list"), ("order", "book_list"))
|
||||
ordering = ("-created_date",)
|
||||
|
|
|
@ -50,11 +50,10 @@ class UserRelationship(BookWyrmModel):
|
|||
),
|
||||
]
|
||||
|
||||
def get_remote_id(self, status=None): # pylint: disable=arguments-differ
|
||||
def get_remote_id(self):
|
||||
""" use shelf identifier in remote_id """
|
||||
status = status or "follows"
|
||||
base_path = self.user_subject.remote_id
|
||||
return "%s#%s/%d" % (base_path, status, self.id)
|
||||
return "%s#follows/%d" % (base_path, self.id)
|
||||
|
||||
|
||||
class UserFollows(ActivityMixin, UserRelationship):
|
||||
|
@ -102,12 +101,15 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
|
|||
|
||||
def save(self, *args, broadcast=True, **kwargs):
|
||||
""" make sure the follow or block relationship doesn't already exist """
|
||||
# don't create a request if a follow already exists
|
||||
# if there's a request for a follow that already exists, accept it
|
||||
# without changing the local database state
|
||||
if UserFollows.objects.filter(
|
||||
user_subject=self.user_subject,
|
||||
user_object=self.user_object,
|
||||
).exists():
|
||||
raise IntegrityError()
|
||||
self.accept(broadcast_only=True)
|
||||
return
|
||||
|
||||
# blocking in either direction is a no-go
|
||||
if UserBlocks.objects.filter(
|
||||
Q(
|
||||
|
@ -138,16 +140,25 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
|
|||
notification_type=notification_type,
|
||||
)
|
||||
|
||||
def accept(self):
|
||||
def get_accept_reject_id(self, status):
|
||||
""" get id for sending an accept or reject of a local user """
|
||||
|
||||
base_path = self.user_object.remote_id
|
||||
return "%s#%s/%d" % (base_path, status, self.id or 0)
|
||||
|
||||
def accept(self, broadcast_only=False):
|
||||
""" turn this request into the real deal"""
|
||||
user = self.user_object
|
||||
if not self.user_subject.local:
|
||||
activity = activitypub.Accept(
|
||||
id=self.get_remote_id(status="accepts"),
|
||||
id=self.get_accept_reject_id(status="accepts"),
|
||||
actor=self.user_object.remote_id,
|
||||
object=self.to_activity(),
|
||||
).serialize()
|
||||
self.broadcast(activity, user)
|
||||
if broadcast_only:
|
||||
return
|
||||
|
||||
with transaction.atomic():
|
||||
UserFollows.from_request(self)
|
||||
self.delete()
|
||||
|
@ -156,7 +167,7 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
|
|||
""" generate a Reject for this follow request """
|
||||
if self.user_object.local:
|
||||
activity = activitypub.Reject(
|
||||
id=self.get_remote_id(status="rejects"),
|
||||
id=self.get_accept_reject_id(status="rejects"),
|
||||
actor=self.user_object.remote_id,
|
||||
object=self.to_activity(),
|
||||
).serialize()
|
||||
|
|
|
@ -48,7 +48,7 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
|
|||
@property
|
||||
def collection_queryset(self):
|
||||
""" list of books for this shelf, overrides OrderedCollectionMixin """
|
||||
return self.books.all().order_by("shelfbook")
|
||||
return self.books.order_by("shelfbook")
|
||||
|
||||
def get_remote_id(self):
|
||||
""" shelf identifier instead of id """
|
||||
|
|
|
@ -24,6 +24,16 @@ from .federated_server import FederatedServer
|
|||
from . import fields, Review
|
||||
|
||||
|
||||
DeactivationReason = models.TextChoices(
|
||||
"DeactivationReason",
|
||||
[
|
||||
"self_deletion",
|
||||
"moderator_deletion",
|
||||
"domain_block",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class User(OrderedCollectionPageMixin, AbstractUser):
|
||||
""" a user who wants to read books """
|
||||
|
||||
|
@ -111,6 +121,9 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
default=str(pytz.utc),
|
||||
max_length=255,
|
||||
)
|
||||
deactivation_reason = models.CharField(
|
||||
max_length=255, choices=DeactivationReason.choices, null=True, blank=True
|
||||
)
|
||||
|
||||
name_field = "username"
|
||||
property_fields = [("following_link", "following")]
|
||||
|
@ -132,13 +145,18 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
return self.name
|
||||
return self.localname or self.username
|
||||
|
||||
@property
|
||||
def deleted(self):
|
||||
""" for consistent naming """
|
||||
return not self.is_active
|
||||
|
||||
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.is_authenticated:
|
||||
if viewer and viewer.is_authenticated:
|
||||
queryset = queryset.exclude(blocks=viewer)
|
||||
return queryset
|
||||
|
||||
|
@ -192,6 +210,9 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
def to_activity(self, **kwargs):
|
||||
"""override default AP serializer to add context object
|
||||
idk if this is the best way to go about this"""
|
||||
if not self.is_active:
|
||||
return self.remote_id
|
||||
|
||||
activity_object = super().to_activity(**kwargs)
|
||||
activity_object["@context"] = [
|
||||
"https://www.w3.org/ns/activitystreams",
|
||||
|
@ -270,6 +291,12 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
editable=False,
|
||||
).save(broadcast=False)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
""" deactivate rather than delete a user """
|
||||
self.is_active = False
|
||||
# skip the logic in this class's save()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def local_path(self):
|
||||
""" this model doesn't inherit bookwyrm model, so here we are """
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue