2021-03-08 16:49:10 +00:00
|
|
|
""" basics for an activitypub serializer """
|
2020-09-17 20:02:52 +00:00
|
|
|
from dataclasses import dataclass, fields, MISSING
|
|
|
|
from json import JSONEncoder
|
|
|
|
|
2020-12-08 17:43:12 +00:00
|
|
|
from django.apps import apps
|
2020-12-21 22:25:10 +00:00
|
|
|
from django.db import IntegrityError, transaction
|
2020-11-23 21:43:46 +00:00
|
|
|
|
2020-12-03 20:35:57 +00:00
|
|
|
from bookwyrm.connectors import ConnectorException, get_data
|
2020-12-08 17:43:12 +00:00
|
|
|
from bookwyrm.tasks import app
|
2020-09-17 20:02:52 +00:00
|
|
|
|
2021-03-08 16:49:10 +00:00
|
|
|
|
2020-11-12 19:59:34 +00:00
|
|
|
class ActivitySerializerError(ValueError):
|
2021-04-26 16:15:42 +00:00
|
|
|
"""routine problems serializing activitypub json"""
|
2020-11-12 19:59:34 +00:00
|
|
|
|
|
|
|
|
2020-09-17 20:02:52 +00:00
|
|
|
class ActivityEncoder(JSONEncoder):
|
2021-04-26 16:15:42 +00:00
|
|
|
"""used to convert an Activity object into json"""
|
2021-03-08 16:49:10 +00:00
|
|
|
|
2020-09-17 20:02:52 +00:00
|
|
|
def default(self, o):
|
|
|
|
return o.__dict__
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
2021-06-18 21:12:56 +00:00
|
|
|
# pylint: disable=invalid-name
|
2020-09-17 20:02:52 +00:00
|
|
|
class Signature:
|
2021-04-26 16:15:42 +00:00
|
|
|
"""public key block"""
|
2021-03-08 16:49:10 +00:00
|
|
|
|
2020-09-17 20:02:52 +00:00
|
|
|
creator: str
|
|
|
|
created: str
|
|
|
|
signatureValue: str
|
2021-03-08 16:49:10 +00:00
|
|
|
type: str = "RsaSignature2017"
|
|
|
|
|
2020-09-17 20:02:52 +00:00
|
|
|
|
2021-02-17 02:59:26 +00:00
|
|
|
def naive_parse(activity_objects, activity_json, serializer=None):
|
2021-08-09 01:40:47 +00:00
|
|
|
"""this navigates circular import issues by looking up models' serializers"""
|
2021-02-17 02:59:26 +00:00
|
|
|
if not serializer:
|
2021-03-08 16:49:10 +00:00
|
|
|
if activity_json.get("publicKeyPem"):
|
2021-02-17 02:59:26 +00:00
|
|
|
# ugh
|
2021-03-08 16:49:10 +00:00
|
|
|
activity_json["type"] = "PublicKey"
|
2021-04-16 22:12:38 +00:00
|
|
|
|
|
|
|
activity_type = activity_json.get("type")
|
2021-02-17 02:59:26 +00:00
|
|
|
try:
|
|
|
|
serializer = activity_objects[activity_type]
|
2021-06-18 21:12:56 +00:00
|
|
|
except KeyError as err:
|
2021-04-16 22:12:38 +00:00
|
|
|
# we know this exists and that we can't handle it
|
|
|
|
if activity_type in ["Question"]:
|
|
|
|
return None
|
2021-06-18 21:12:56 +00:00
|
|
|
raise ActivitySerializerError(err)
|
2021-02-16 01:23:17 +00:00
|
|
|
|
|
|
|
return serializer(activity_objects=activity_objects, **activity_json)
|
2020-09-17 20:02:52 +00:00
|
|
|
|
2021-02-16 04:49:23 +00:00
|
|
|
|
2020-09-17 20:02:52 +00:00
|
|
|
@dataclass(init=False)
|
|
|
|
class ActivityObject:
|
2021-04-26 16:15:42 +00:00
|
|
|
"""actor activitypub json"""
|
2021-03-08 16:49:10 +00:00
|
|
|
|
2020-09-17 20:02:52 +00:00
|
|
|
id: str
|
|
|
|
type: str
|
|
|
|
|
2021-02-16 01:23:17 +00:00
|
|
|
def __init__(self, activity_objects=None, **kwargs):
|
2021-03-08 16:49:10 +00:00
|
|
|
"""this lets you pass in an object with fields that aren't in the
|
2020-10-17 02:13:18 +00:00
|
|
|
dataclass, which it ignores. Any field in the dataclass is required or
|
2021-03-08 16:49:10 +00:00
|
|
|
has a default value"""
|
2020-09-17 20:02:52 +00:00
|
|
|
for field in fields(self):
|
|
|
|
try:
|
|
|
|
value = kwargs[field.name]
|
2021-03-17 16:22:45 +00:00
|
|
|
if value in (None, MISSING, {}):
|
2021-02-17 03:28:23 +00:00
|
|
|
raise KeyError()
|
2021-02-16 02:47:08 +00:00
|
|
|
try:
|
|
|
|
is_subclass = issubclass(field.type, ActivityObject)
|
|
|
|
except TypeError:
|
|
|
|
is_subclass = False
|
2021-02-17 18:30:02 +00:00
|
|
|
# serialize a model obj
|
2021-03-08 16:49:10 +00:00
|
|
|
if hasattr(value, "to_activity"):
|
2021-02-17 18:30:02 +00:00
|
|
|
value = value.to_activity()
|
2021-02-17 04:17:38 +00:00
|
|
|
# parse a dict into the appropriate activity
|
2021-02-17 18:30:02 +00:00
|
|
|
elif is_subclass and isinstance(value, dict):
|
2021-02-17 20:23:55 +00:00
|
|
|
if activity_objects:
|
|
|
|
value = naive_parse(activity_objects, value)
|
|
|
|
else:
|
2021-02-24 01:18:25 +00:00
|
|
|
value = naive_parse(
|
2021-03-08 16:49:10 +00:00
|
|
|
activity_objects, value, serializer=field.type
|
|
|
|
)
|
2021-02-16 01:23:17 +00:00
|
|
|
|
2020-09-17 20:02:52 +00:00
|
|
|
except KeyError:
|
2021-03-08 16:49:10 +00:00
|
|
|
if field.default == MISSING and field.default_factory == MISSING:
|
|
|
|
raise ActivitySerializerError(
|
2021-09-18 04:39:18 +00:00
|
|
|
f"Missing required field: {field.name}"
|
2021-03-08 16:49:10 +00:00
|
|
|
)
|
2020-09-17 20:02:52 +00:00
|
|
|
value = field.default
|
|
|
|
setattr(self, field.name, value)
|
|
|
|
|
2021-08-17 17:49:11 +00:00
|
|
|
# pylint: disable=too-many-locals,too-many-branches,too-many-arguments
|
2021-08-17 17:08:07 +00:00
|
|
|
def to_model(
|
|
|
|
self, model=None, instance=None, allow_create=True, save=True, overwrite=True
|
|
|
|
):
|
2021-04-26 16:15:42 +00:00
|
|
|
"""convert from an activity to a model instance"""
|
2021-02-16 19:04:13 +00:00
|
|
|
model = model or get_model_from_type(self.type)
|
2020-09-17 20:02:52 +00:00
|
|
|
|
2021-02-17 00:35:28 +00:00
|
|
|
# only reject statuses if we're potentially creating them
|
2021-03-08 16:49:10 +00:00
|
|
|
if (
|
|
|
|
allow_create
|
|
|
|
and hasattr(model, "ignore_activity")
|
|
|
|
and model.ignore_activity(self)
|
|
|
|
):
|
2021-04-09 03:03:29 +00:00
|
|
|
return None
|
2020-12-18 20:38:27 +00:00
|
|
|
|
2021-02-16 05:20:00 +00:00
|
|
|
# check for an existing instance
|
2021-02-16 04:49:23 +00:00
|
|
|
instance = instance or model.find_existing(self.serialize())
|
2021-02-17 00:35:28 +00:00
|
|
|
|
2021-02-16 04:49:23 +00:00
|
|
|
if not instance and not allow_create:
|
2021-02-16 05:20:00 +00:00
|
|
|
# so that we don't create when we want to delete or update
|
2021-02-16 04:49:23 +00:00
|
|
|
return None
|
|
|
|
instance = instance or model()
|
2020-12-03 20:35:57 +00:00
|
|
|
|
2021-08-03 15:48:15 +00:00
|
|
|
# keep track of what we've changed
|
|
|
|
update_fields = []
|
2021-08-17 17:08:07 +00:00
|
|
|
# sets field on the model using the activity value
|
2020-12-13 20:02:26 +00:00
|
|
|
for field in instance.simple_fields:
|
2021-02-17 04:24:37 +00:00
|
|
|
try:
|
2021-08-17 17:08:07 +00:00
|
|
|
changed = field.set_field_from_activity(
|
|
|
|
instance, self, overwrite=overwrite
|
|
|
|
)
|
2021-08-03 15:48:15 +00:00
|
|
|
if changed:
|
|
|
|
update_fields.append(field.name)
|
2021-02-17 04:24:37 +00:00
|
|
|
except AttributeError as e:
|
|
|
|
raise ActivitySerializerError(e)
|
2020-12-08 02:28:42 +00:00
|
|
|
|
2020-12-13 20:02:26 +00:00
|
|
|
# image fields have to be set after other fields because they can save
|
|
|
|
# too early and jank up users
|
|
|
|
for field in instance.image_fields:
|
2021-08-17 17:08:07 +00:00
|
|
|
changed = field.set_field_from_activity(
|
|
|
|
instance, self, save=save, overwrite=overwrite
|
|
|
|
)
|
2021-08-03 15:48:15 +00:00
|
|
|
if changed:
|
|
|
|
update_fields.append(field.name)
|
2020-12-12 23:44:17 +00:00
|
|
|
|
2020-12-08 17:43:12 +00:00
|
|
|
if not save:
|
|
|
|
return instance
|
|
|
|
|
2020-12-20 02:34:37 +00:00
|
|
|
with transaction.atomic():
|
2021-08-03 15:48:15 +00:00
|
|
|
# can't force an update on fields unless the object already exists in the db
|
|
|
|
if not instance.id:
|
|
|
|
update_fields = None
|
2020-12-20 02:34:37 +00:00
|
|
|
# we can't set many to many and reverse fields on an unsaved object
|
2020-12-21 22:25:10 +00:00
|
|
|
try:
|
2021-02-08 17:38:28 +00:00
|
|
|
try:
|
2021-08-03 15:48:15 +00:00
|
|
|
instance.save(broadcast=False, update_fields=update_fields)
|
2021-02-08 17:38:28 +00:00
|
|
|
except TypeError:
|
2021-08-03 15:48:15 +00:00
|
|
|
instance.save(update_fields=update_fields)
|
2020-12-21 22:25:10 +00:00
|
|
|
except IntegrityError as e:
|
|
|
|
raise ActivitySerializerError(e)
|
2020-11-29 01:29:03 +00:00
|
|
|
|
2020-12-20 02:34:37 +00:00
|
|
|
# add many to many fields, which have to be set post-save
|
|
|
|
for field in instance.many_to_many_fields:
|
|
|
|
# mention books/users, for example
|
|
|
|
field.set_field_from_activity(instance, self)
|
2020-12-08 02:28:42 +00:00
|
|
|
|
|
|
|
# reversed relationships in the models
|
2021-03-08 16:49:10 +00:00
|
|
|
for (
|
|
|
|
model_field_name,
|
|
|
|
activity_field_name,
|
|
|
|
) in instance.deserialize_reverse_fields:
|
2020-12-08 02:28:42 +00:00
|
|
|
# attachments on Status, for example
|
|
|
|
values = getattr(self, activity_field_name)
|
|
|
|
if values is None or values is MISSING:
|
2020-11-29 01:29:03 +00:00
|
|
|
continue
|
2020-12-20 02:34:37 +00:00
|
|
|
|
|
|
|
model_field = getattr(model, model_field_name)
|
2020-12-23 20:45:40 +00:00
|
|
|
# creating a Work, model_field is 'editions'
|
|
|
|
# creating a User, model field is 'key_pair'
|
|
|
|
related_model = model_field.field.model
|
2020-12-23 21:33:46 +00:00
|
|
|
related_field_name = model_field.field.name
|
2020-12-08 02:28:42 +00:00
|
|
|
|
2020-11-29 01:29:03 +00:00
|
|
|
for item in values:
|
2020-12-08 17:43:12 +00:00
|
|
|
set_related_field.delay(
|
|
|
|
related_model.__name__,
|
|
|
|
instance.__class__.__name__,
|
2020-12-20 02:34:37 +00:00
|
|
|
related_field_name,
|
2020-12-08 17:43:12 +00:00
|
|
|
instance.remote_id,
|
2021-03-08 16:49:10 +00:00
|
|
|
item,
|
2020-12-08 17:43:12 +00:00
|
|
|
)
|
2020-11-20 17:59:55 +00:00
|
|
|
return instance
|
2020-09-17 20:02:52 +00:00
|
|
|
|
2021-12-16 01:10:59 +00:00
|
|
|
def serialize(self, **kwargs):
|
2021-04-26 16:15:42 +00:00
|
|
|
"""convert to dictionary with context attr"""
|
2021-12-16 01:10:59 +00:00
|
|
|
omit = kwargs.get("omit", ())
|
2021-02-17 17:33:33 +00:00
|
|
|
data = self.__dict__.copy()
|
2021-02-16 19:04:13 +00:00
|
|
|
# recursively serialize
|
|
|
|
for (k, v) in data.items():
|
|
|
|
try:
|
2021-02-17 17:33:33 +00:00
|
|
|
if issubclass(type(v), ActivityObject):
|
|
|
|
data[k] = v.serialize()
|
2021-02-16 19:04:13 +00:00
|
|
|
except TypeError:
|
2021-02-17 17:33:33 +00:00
|
|
|
pass
|
2021-12-16 01:10:59 +00:00
|
|
|
data = {k: v for (k, v) in data.items() if v is not None and k not in omit}
|
|
|
|
if "@context" not in omit:
|
|
|
|
data["@context"] = "https://www.w3.org/ns/activitystreams"
|
2020-09-17 20:02:52 +00:00
|
|
|
return data
|
|
|
|
|
|
|
|
|
2021-09-07 23:33:43 +00:00
|
|
|
@app.task(queue="medium_priority")
|
2020-12-08 17:43:12 +00:00
|
|
|
@transaction.atomic
|
|
|
|
def set_related_field(
|
2021-03-08 16:49:10 +00:00
|
|
|
model_name, origin_model_name, related_field_name, related_remote_id, data
|
|
|
|
):
|
2021-04-26 16:15:42 +00:00
|
|
|
"""load reverse related fields (editions, attachments) without blocking"""
|
2021-09-18 04:39:18 +00:00
|
|
|
model = apps.get_model(f"bookwyrm.{model_name}", require_ready=True)
|
|
|
|
origin_model = apps.get_model(f"bookwyrm.{origin_model_name}", require_ready=True)
|
2020-12-08 17:43:12 +00:00
|
|
|
|
2022-01-13 00:41:23 +00:00
|
|
|
if isinstance(data, str):
|
|
|
|
existing = model.find_existing_by_remote_id(data)
|
|
|
|
if existing:
|
|
|
|
data = existing.to_activity()
|
|
|
|
else:
|
|
|
|
data = get_data(data)
|
|
|
|
activity = model.activity_serializer(**data)
|
|
|
|
|
|
|
|
# this must exist because it's the object that triggered this function
|
|
|
|
instance = origin_model.find_existing_by_remote_id(related_remote_id)
|
|
|
|
if not instance:
|
|
|
|
raise ValueError(f"Invalid related remote id: {related_remote_id}")
|
|
|
|
|
|
|
|
# set the origin's remote id on the activity so it will be there when
|
|
|
|
# the model instance is created
|
|
|
|
# edition.parentWork = instance, for example
|
|
|
|
model_field = getattr(model, related_field_name)
|
|
|
|
if hasattr(model_field, "activitypub_field"):
|
2022-01-13 01:11:24 +00:00
|
|
|
setattr(activity, getattr(model_field, "activitypub_field"), instance.remote_id)
|
2022-02-09 17:20:11 +00:00
|
|
|
item = activity.to_model(model=model)
|
2022-01-13 00:41:23 +00:00
|
|
|
|
|
|
|
# if the related field isn't serialized (attachments on Status), then
|
|
|
|
# we have to set it post-creation
|
|
|
|
if not hasattr(model_field, "activitypub_field"):
|
|
|
|
setattr(item, related_field_name, instance)
|
|
|
|
item.save()
|
2020-12-08 17:43:12 +00:00
|
|
|
|
|
|
|
|
2021-02-16 19:04:13 +00:00
|
|
|
def get_model_from_type(activity_type):
|
2021-04-26 16:15:42 +00:00
|
|
|
"""given the activity, what type of model"""
|
2021-02-16 19:04:13 +00:00
|
|
|
models = apps.get_models()
|
2021-03-08 16:49:10 +00:00
|
|
|
model = [
|
|
|
|
m
|
|
|
|
for m in models
|
|
|
|
if hasattr(m, "activity_serializer")
|
|
|
|
and hasattr(m.activity_serializer, "type")
|
|
|
|
and m.activity_serializer.type == activity_type
|
|
|
|
]
|
2021-02-17 02:59:26 +00:00
|
|
|
if not model:
|
2021-02-16 19:04:13 +00:00
|
|
|
raise ActivitySerializerError(
|
2021-09-18 04:39:18 +00:00
|
|
|
f'No model found for activity type "{activity_type}"'
|
2021-03-08 16:49:10 +00:00
|
|
|
)
|
2021-02-16 19:04:13 +00:00
|
|
|
return model[0]
|
|
|
|
|
2021-02-17 02:59:26 +00:00
|
|
|
|
2021-03-24 19:37:42 +00:00
|
|
|
def resolve_remote_id(
|
|
|
|
remote_id, model=None, refresh=False, save=True, get_activity=False
|
|
|
|
):
|
2021-04-26 16:15:42 +00:00
|
|
|
"""take a remote_id and return an instance, creating if necessary"""
|
2021-03-08 16:49:10 +00:00
|
|
|
if model: # a bonus check we can do if we already know the model
|
2021-08-28 17:40:52 +00:00
|
|
|
if isinstance(model, str):
|
|
|
|
model = apps.get_model(f"bookwyrm.{model}", require_ready=True)
|
2021-02-16 19:04:13 +00:00
|
|
|
result = model.find_existing_by_remote_id(remote_id)
|
|
|
|
if result and not refresh:
|
2021-03-24 19:58:39 +00:00
|
|
|
return result if not get_activity else result.to_activity_dataclass()
|
2020-09-17 20:02:52 +00:00
|
|
|
|
2020-11-28 18:18:24 +00:00
|
|
|
# load the data and create the object
|
2020-11-28 06:10:38 +00:00
|
|
|
try:
|
2020-11-29 17:40:15 +00:00
|
|
|
data = get_data(remote_id)
|
2021-03-13 16:13:20 +00:00
|
|
|
except ConnectorException:
|
2020-11-28 06:10:38 +00:00
|
|
|
raise ActivitySerializerError(
|
2021-09-18 04:39:18 +00:00
|
|
|
f"Could not connect to host for remote_id: {remote_id}"
|
2021-03-08 16:49:10 +00:00
|
|
|
)
|
2021-02-16 19:04:13 +00:00
|
|
|
# determine the model implicitly, if not provided
|
2021-04-06 01:05:06 +00:00
|
|
|
# or if it's a model with subclasses like Status, check again
|
|
|
|
if not model or hasattr(model.objects, "select_subclasses"):
|
2021-03-08 16:49:10 +00:00
|
|
|
model = get_model_from_type(data.get("type"))
|
2020-11-28 06:10:38 +00:00
|
|
|
|
2020-12-12 21:39:55 +00:00
|
|
|
# check for existing items with shared unique identifiers
|
2021-02-16 19:04:13 +00:00
|
|
|
result = model.find_existing(data)
|
|
|
|
if result and not refresh:
|
2021-03-24 19:58:39 +00:00
|
|
|
return result if not get_activity else result.to_activity_dataclass()
|
2020-12-12 21:39:55 +00:00
|
|
|
|
2020-11-29 17:40:15 +00:00
|
|
|
item = model.activity_serializer(**data)
|
2021-03-24 19:37:42 +00:00
|
|
|
if get_activity:
|
|
|
|
return item
|
|
|
|
|
2020-11-28 18:18:24 +00:00
|
|
|
# if we're refreshing, "result" will be set and we'll update it
|
2021-02-16 19:04:13 +00:00
|
|
|
return item.to_model(model=model, instance=result, save=save)
|
2021-12-16 01:10:59 +00:00
|
|
|
|
|
|
|
|
|
|
|
@dataclass(init=False)
|
|
|
|
class Link(ActivityObject):
|
|
|
|
"""for tagging a book in a status"""
|
|
|
|
|
|
|
|
href: str
|
2022-01-10 19:21:43 +00:00
|
|
|
name: str = None
|
2021-12-16 01:10:59 +00:00
|
|
|
mediaType: str = None
|
|
|
|
id: str = None
|
2022-01-10 21:20:14 +00:00
|
|
|
attributedTo: str = None
|
2022-02-09 17:20:11 +00:00
|
|
|
availability: str = None
|
2021-12-16 01:10:59 +00:00
|
|
|
type: str = "Link"
|
|
|
|
|
|
|
|
def serialize(self, **kwargs):
|
|
|
|
"""remove fields"""
|
|
|
|
omit = ("id", "type", "@context")
|
|
|
|
return super().serialize(omit=omit)
|
|
|
|
|
2022-02-09 17:23:01 +00:00
|
|
|
|
2021-12-16 01:10:59 +00:00
|
|
|
@dataclass(init=False)
|
|
|
|
class Mention(Link):
|
|
|
|
"""a subtype of Link for mentioning an actor"""
|
|
|
|
|
|
|
|
type: str = "Mention"
|