kopia lustrzana https://gitlab.com/marnanel/chapeau
464 wiersze
12 KiB
Python
464 wiersze
12 KiB
Python
from django.db import models
|
|
from django_kepi import object_type_registry, resolve, register_type, logger
|
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
|
from django.contrib.contenttypes.models import ContentType
|
|
from django.conf import settings
|
|
import django_kepi.tasks
|
|
import logging
|
|
import random
|
|
import json
|
|
import datetime
|
|
import warnings
|
|
import uuid
|
|
|
|
#######################
|
|
|
|
class QuarantinedMessage(models.Model):
|
|
|
|
username = models.CharField(
|
|
max_length=255,
|
|
blank=True,
|
|
null=True,
|
|
default=None)
|
|
|
|
headers = models.TextField()
|
|
body = models.TextField()
|
|
|
|
signature_verified = models.BooleanField(
|
|
default=False,
|
|
)
|
|
|
|
def deploy(self,
|
|
retrying=False):
|
|
|
|
if retrying:
|
|
logger.debug('%s: re-attempting to deploy', self)
|
|
|
|
remaining_qmns = QuarantinedMessageNeeds.objects.filter(
|
|
message=self.pk,
|
|
)
|
|
|
|
if remaining_qmns.exists():
|
|
logger.debug('%s: -- but there are dependencies remaining: %s',
|
|
self, remaining_qmns)
|
|
return None
|
|
else:
|
|
logger.debug('%s: attempting to deploy', self)
|
|
|
|
try:
|
|
value = json.loads(self.body)
|
|
except json.decoder.JSONDecodeError:
|
|
logger.info('%s: JSON was invalid; deleting', self)
|
|
self.delete()
|
|
return None
|
|
|
|
activity = Activity.create(
|
|
value = value,
|
|
local = False,
|
|
from_message = self,
|
|
)
|
|
|
|
if activity is None:
|
|
logger.debug('%s: deployment failed because dependencies remain',
|
|
self)
|
|
|
|
if retrying:
|
|
logger.error("%s: dependencies remaining when all dependency records were gone; this should never happen")
|
|
raise RuntimeError("dependencies remaining on retry")
|
|
|
|
return None
|
|
else:
|
|
logger.info('%s: deployment was successful', self)
|
|
self.delete()
|
|
|
|
return activity
|
|
|
|
def __str__(self):
|
|
return 'QM{}'.format(self.pk)
|
|
|
|
class QuarantinedMessageNeeds(models.Model):
|
|
|
|
class Meta:
|
|
index_together = ["message", "needs_to_fetch"]
|
|
|
|
id = models.UUIDField(
|
|
primary_key=True,
|
|
default=uuid.uuid4,
|
|
editable=False,
|
|
)
|
|
|
|
message = models.ForeignKey(QuarantinedMessage,
|
|
on_delete=models.CASCADE)
|
|
|
|
# TODO: add indexing when we have tests working
|
|
|
|
needs_to_fetch = models.URLField()
|
|
|
|
def start_looking(self):
|
|
django_kepi.tasks.fetch.delay(
|
|
fetch_url = self.needs_to_fetch,
|
|
post_data = None,
|
|
result_url = 'https://localhost/async_result', # XXX
|
|
result_id = self.id,
|
|
)
|
|
|
|
def __str__(self):
|
|
return 'QM{} needs {}'.format(
|
|
self.message.pk,
|
|
self.needs_to_fetch,
|
|
)
|
|
|
|
#######################
|
|
|
|
def new_activity_identifier():
|
|
template = settings.KEPI['ACTIVITY_URL_FORMAT']
|
|
slug = '%08x' % (random.randint(0, 0xffffffff),)
|
|
return template % (slug,)
|
|
|
|
#######################
|
|
|
|
def _object_to_id_and_type(obj):
|
|
"""
|
|
Takes an object passed in to Activity.create(),
|
|
and returns a (url, type) pair to find it with
|
|
lookup().
|
|
|
|
"type" may be None if we can't determine a type,
|
|
so lookup() will have to check everything.
|
|
"""
|
|
|
|
# Is it a string?
|
|
if isinstance(obj, str):
|
|
return obj, None
|
|
|
|
# Maybe it has an activity_id property.
|
|
try:
|
|
check_url = obj.activity_id
|
|
|
|
try:
|
|
check_type = obj.activity_type
|
|
except AttributeError:
|
|
check_type = None
|
|
|
|
return check_url, check_type
|
|
|
|
except AttributeError:
|
|
pass # nope, try something else
|
|
|
|
# Maybe it's a dict with 'id' and 'type' fields.
|
|
try:
|
|
check_url = obj['id']
|
|
|
|
try:
|
|
check_type = obj['type']
|
|
except KeyError:
|
|
check_type = None
|
|
|
|
return check_url, check_type
|
|
|
|
except KeyError:
|
|
# So it *does* have fields, but "id" isn't
|
|
# one of them. This breaks preconditions.
|
|
raise ValueError('Explicit objects must have an id')
|
|
|
|
except TypeError:
|
|
pass # Can't subscript at all.
|
|
|
|
#######################
|
|
|
|
class Activity(models.Model):
|
|
|
|
CREATE='C'
|
|
UPDATE='U'
|
|
DELETE='D'
|
|
FOLLOW='F'
|
|
ADD='+'
|
|
REMOVE='-'
|
|
LIKE='L'
|
|
UNDO='U'
|
|
ACCEPT='A'
|
|
REJECT='R'
|
|
|
|
ACTIVITY_TYPE_CHOICES = (
|
|
(CREATE, 'Create'),
|
|
(UPDATE, 'Update'),
|
|
(DELETE, 'Delete'),
|
|
(FOLLOW, 'Follow'),
|
|
(ADD, 'Add'),
|
|
(REMOVE, 'Remove'),
|
|
(LIKE, 'Like'),
|
|
(UNDO, 'Undo'),
|
|
(ACCEPT, 'Accept'),
|
|
(REJECT, 'Reject'),
|
|
)
|
|
|
|
f_type = models.CharField(
|
|
max_length=1,
|
|
choices=ACTIVITY_TYPE_CHOICES,
|
|
)
|
|
|
|
identifier = models.URLField(
|
|
max_length=255,
|
|
primary_key=True,
|
|
default=new_activity_identifier,
|
|
)
|
|
|
|
f_actor = models.URLField(
|
|
max_length=255,
|
|
blank=True,
|
|
)
|
|
|
|
f_object_type = models.CharField(
|
|
max_length=255,
|
|
blank=True,
|
|
)
|
|
|
|
f_object = models.URLField(
|
|
max_length=255,
|
|
blank=True,
|
|
)
|
|
|
|
f_target = models.URLField(
|
|
max_length=255,
|
|
blank=True,
|
|
)
|
|
|
|
active = models.BooleanField(
|
|
default=True,
|
|
)
|
|
|
|
accepted = models.BooleanField(
|
|
default=False,
|
|
)
|
|
|
|
# XXX Updates from clients are partial,
|
|
# but updates from remote sites are total.
|
|
# We don't currently let clients create Activities,
|
|
# but if we ever do, we should flag which it was.
|
|
|
|
def __str__(self):
|
|
|
|
if self.active:
|
|
inactive_warning = ''
|
|
else:
|
|
inactive_warning = ' INACTIVE'
|
|
|
|
result = '[%s %s%s]' % (
|
|
self.f_type,
|
|
self.identifier,
|
|
inactive_warning,
|
|
)
|
|
return result
|
|
|
|
@property
|
|
def activity_id(self):
|
|
return self.identifier
|
|
|
|
@property
|
|
def activity_type(self):
|
|
return self.f_type
|
|
|
|
@property
|
|
def activity(self):
|
|
result = {
|
|
'id': self.identifier,
|
|
'f_type': self.get_f_type_display(),
|
|
}
|
|
|
|
for optional in ['actor', 'object', 'published', 'updated', 'target']:
|
|
if optional=='object':
|
|
fieldname='fobject'
|
|
else:
|
|
fieldname=optional
|
|
|
|
value = getattr(self, fieldname)
|
|
if value is not None:
|
|
result[optional] = value
|
|
|
|
# XXX should we mark "inactive" somehow?
|
|
|
|
return result
|
|
|
|
TYPES = {
|
|
# actor object target
|
|
'Create': (True, True, False),
|
|
'Update': (True, True, False),
|
|
'Delete': (True, True, False),
|
|
'Follow': (True, True, False),
|
|
'Add': (True, False, True),
|
|
'Remove': (True, False, True),
|
|
'Like': (True, True, False),
|
|
'Undo': (False, True, False),
|
|
'Accept': (True, True, False),
|
|
'Reject': (True, True, False),
|
|
}
|
|
|
|
def deploy(self):
|
|
"""
|
|
Some kinds of Activity have side-effects
|
|
when they're created. This method carries out
|
|
those side-effects.
|
|
"""
|
|
if self.f_type=='Accept':
|
|
|
|
referent = resolve(
|
|
identifier=self.f_object,
|
|
f_type='Follow',
|
|
)
|
|
|
|
if referent is not None:
|
|
referent.accepted = True
|
|
referent.save()
|
|
|
|
elif self.f_type=='Reject':
|
|
|
|
referent = resolve(
|
|
identifier=self.f_object,
|
|
f_type='Follow',
|
|
)
|
|
|
|
if referent is not None:
|
|
referent.accepted = False
|
|
referent.active = False
|
|
referent.save()
|
|
|
|
|
|
@classmethod
|
|
def register_all_activity_types(cls):
|
|
for t in cls.TYPES.keys():
|
|
register_type(t, cls)
|
|
|
|
@classmethod
|
|
def find_activity(cls, url):
|
|
return cls.objects.get(identifier=url)
|
|
|
|
@classmethod
|
|
def activitypub_create(cls, fields):
|
|
return cls.create(value, local=False)
|
|
|
|
@classmethod
|
|
def create(cls, value,
|
|
local=False,
|
|
from_message=None):
|
|
|
|
logger.debug('Creating Activity from %s', str(value))
|
|
|
|
if 'type' not in value:
|
|
raise ValueError("Activities must have a type")
|
|
|
|
if 'id' not in value and not local:
|
|
raise ValueError("Remote activities must have an id")
|
|
|
|
fields = {
|
|
'identifier': value.get('id', None),
|
|
'f_type': value['type'],
|
|
'active': True,
|
|
}
|
|
|
|
try:
|
|
need_actor, need_object, need_target = cls.TYPES[value['type']]
|
|
except KeyError:
|
|
raise ValueError('{} is not an Activity type'.format(value['type']))
|
|
|
|
if need_actor!=('actor' in value) or \
|
|
need_object!=('object' in value) or \
|
|
need_target!=('target' in value):
|
|
|
|
def params(a, o, t):
|
|
result = []
|
|
if a: result.append('actor')
|
|
if o: result.append('object')
|
|
if t: result.append('target')
|
|
|
|
return '['+' '.join(result)+']'
|
|
|
|
we_have = params(
|
|
'actor' in value,
|
|
'object' in value,
|
|
'target' in value,
|
|
)
|
|
|
|
we_need = params(
|
|
need_actor,
|
|
need_object,
|
|
need_target,
|
|
)
|
|
|
|
raise ValueError('Wrong parameters for type {}: we have {}, we need {}'.format(
|
|
value['type'],
|
|
we_have, we_need))
|
|
|
|
# TODO: Sometimes an incoming Activity is trustworthy in
|
|
# telling us about a remote object. At present, for
|
|
# simplicity, we don't trust anybody. If we don't have
|
|
# the object in the cache, we must fetch it.
|
|
|
|
# In each case, the field is either specified as
|
|
# a Link or as an Object. If it's a Link, it will
|
|
# consist of a single string, which is our URL.
|
|
# If it's an Object, it will be a dict whose 'id'
|
|
# field is our URL.
|
|
|
|
unresolved_references = set()
|
|
|
|
for fieldname in ('actor', 'object', 'target'):
|
|
|
|
if fieldname not in value:
|
|
# if it's not there, it's not supposed to be there:
|
|
# we checked for that earlier.
|
|
continue
|
|
|
|
obj_id, obj_type = _object_to_id_and_type(value[fieldname])
|
|
|
|
referent = resolve(
|
|
identifier=obj_id,
|
|
f_type=obj_type,
|
|
)
|
|
|
|
if referent is None:
|
|
|
|
# oh, weird. Maybe they got the type wrong.
|
|
referent = resolve(
|
|
identifier=obj_id,
|
|
f_type=None,
|
|
)
|
|
|
|
if referent is None:
|
|
unresolved_references.add(obj_id)
|
|
|
|
# okay, we can let them use it
|
|
|
|
fields['f_'+fieldname] = obj_id
|
|
|
|
if unresolved_references:
|
|
logger.debug('Unresolved references: %s', str(unresolved_references))
|
|
|
|
if from_message is None:
|
|
logger.warn('Unresolved references in Activity with no parent message: %s',
|
|
str(unresolved_references))
|
|
return None
|
|
else:
|
|
for need in unresolved_references:
|
|
qmn = QuarantinedMessageNeeds(
|
|
message=from_message,
|
|
needs_to_fetch=need,
|
|
)
|
|
qmn.save()
|
|
logger.info(' -- %s', qmn)
|
|
qmn.start_looking()
|
|
return None
|
|
|
|
result = cls(**fields)
|
|
result.save()
|
|
result.deploy()
|
|
|
|
return result
|
|
|
|
# TODO: there should be a clean() method with the same
|
|
# checks as create().
|
|
|
|
########################################
|
|
|
|
Activity.register_all_activity_types()
|
|
|