Split out the part of bowler_pub that sends ActivityPub notifications

into a new app, sombrero_sendpub. Tests not yet passing.
trilby-heavy
Marnanel Thurman 2020-05-01 13:48:14 +01:00
rodzic 388ce027a6
commit e52dffe9ff
26 zmienionych plików z 23 dodań i 1412 usunięć

Wyświetl plik

@ -1,192 +0,0 @@
# create.py
#
# Part of kepi, an ActivityPub daemon and library.
# Copyright (c) 2018-2019 Marnanel Thurman.
# Licensed under the GNU Public License v2.
"""
This module contains create(), which creates objects.
"""
import django.db.utils
import logging
import json
logger = logging.getLogger(name='kepi')
def _fix_value_type(v):
if type(v) in [str, int, bool]:
# simple types are fine
return v
try:
result = v.id
except AttributeError:
result = v
return result
def create(
value=None,
is_local_user=True,
run_side_effects=True,
run_delivery=True,
send_signal=True,
incoming=False,
**kwargs):
"""
Creates a bowler_pub object.
Keyword arguments:
value -- the fields of the new object, as a dict.
Must contain a key "type".
is_local_user -- ignored (FIXME)
run_side_effects -- whether the new object should cause
its usual side effects. For example, creating a Delete
object should have the side-effect of deleting something.
run_delivery -- whether we should attempt to deliver the
new object to whatever audiences it lists.
send_signal -- whether we should send signals.created
about the new object
Any extra keyword arguments are taken to be fields of the
new object, just as if they had appeared in "value".
Any of these keywords may be prefixed with "f_" to avoid
confusing Python's parser. For example, you could write
f_type = "Create"
to set the "type" field to "Create".
Don't confuse create() with objects of type Create!
"""
from kepi.bowler_pub.delivery import deliver
from kepi.bowler_pub.models.activity import AcActivity
from kepi.bowler_pub.models.acobject import AcObject
if value is None:
value = {}
# Remove the "f_" prefix, which exists so that we can write
# things like f_type or f_object without using reserved keywords.
for k,v in kwargs.items():
if k.startswith('f_'):
value[k[2:]] = v
else:
value[k] = v
logger.info("Create begins: source is %s; run side effects? %s",
value, run_side_effects)
if value is None:
logger.warn(" -- it's ludicrous to create an Object with no value")
return None
# Now, let's fix the types of keys and values.
if 'type' not in value:
logger.warn("Objects must have a type; dropping message")
return None
value['type'] = value['type'].title()
for k,v in value.copy().items():
if not isinstance(k, str):
logger.warn('Objects can only have keys which are strings: %s',
str(k))
del value[k]
class_name = 'Ac'+value['type']
try:
import kepi.bowler_pub.models as bowler_pub_models
cls = getattr(locals()['bowler_pub_models'], class_name)
except AttributeError:
logger.warn("There's no type called %s",
class_name)
return None
except KeyError:
logger.warn("The class '%s' wasn't exported properly. "+\
"This shouldn't happen.",
class_name)
return None
logger.debug('Class for %s is %s', value['type'], cls)
del value['type']
if 'id' in value and 'url' in value:
if value['id']!=value['url']:
logger.warn('id and url differ (%s vs %s)',
value['id'], value['url'])
del value['url']
########################
# Split out the values which have a f_* field in the class.
primary_values = {}
class_fields = dir(cls)
for f,v in list(value.items()):
if f=='id':
new_fieldname = f
else:
new_fieldname = 'f_'+f
if new_fieldname in class_fields:
primary_values[new_fieldname] = _fix_value_type(v)
del value[f]
logger.debug('Primary values are %s; others are %s',
primary_values, value)
########################
# Right, we need to create an object.
try:
result = cls(
**primary_values,
)
result.save()
logger.info(' -- created object %s',
result)
except django.db.utils.IntegrityError:
logger.warn('We already have an object with id=%s; ignoring',
value['id'])
return None
for f,v in value.items():
try:
result[f] = _fix_value_type(v)
except django.db.utils.Error as pe:
logger.warn('Can\'t set %s=%s on the new object (%s); bailing',
f, v, pe)
return None
if hasattr(result, '_after_create'):
result._after_create()
if run_side_effects:
success = result.run_side_effects(
send_signal = send_signal,
)
if not success:
logger.debug(' -- side-effects failed; deleting original object')
result.delete()
return None
if run_delivery and isinstance(result, AcActivity):
deliver(result.id,
incoming = incoming)
if send_signal:
logger.debug(' -- sending "created" signal')
signals.created.send(
sender = AcObject,
value = result,
)
return result

Wyświetl plik

@ -1,345 +0,0 @@
# find.py
#
# Part of kepi, an ActivityPub daemon.
# Copyright (c) 2018-2019 Marnanel Thurman.
# Licensed under the GNU Public License v2.
"""
This module contains find(), which finds objects.
"""
from django.db import models
import requests
import logging
from django.conf import settings
import django.urls
from urllib.parse import urlparse
from django.http.request import HttpRequest
from kepi.bowler_pub.create import create
from django.utils import timezone
from kepi.bowler_pub.utils import is_short_id
import json
import mimeparse
logger = logging.getLogger(name='kepi')
class Fetch(models.Model):
"""
A record of something having been fetched from a
particular URL at a particular time. It doesn't
contain the actual data; it just keeps the cache fresh.
"""
url = models.URLField(
primary_key = True,
)
date = models.DateTimeField(
default = timezone.now,
)
class ActivityRequest(HttpRequest):
"""
These are fake HttpRequests which we send to the views
as an ACTIVITY_GET or ACTIVITY_STORE method.
For more information, see the docstring in views/.
"""
def __init__(self, path, object_to_store):
super().__init__()
self.path = path
if object_to_store is None:
self.method = 'ACTIVITY_GET'
else:
self.method = 'ACTIVITY_STORE'
self.activity = object_to_store
class TombstoneException(Exception):
# XXX obsolete; remove
def __init__(self, tombstone, *args, **kwargs):
self.tombstone = tombstone
super().__init__(*args, **kwargs)
def __str__(self):
return self.tombstone.__str__()
###################################
COLLECTION_TYPES = set([
'OrderedCollection',
'OrderedCollectionPage',
])
class Collection(dict):
"""
The contents of a remote object of any type in COLLECTION_TYPES.
"""
pass
###################################
def find_local(path,
object_to_store=None):
"""
Finds a local object and returns it.
Optionally, passes it another object.
path -- the path to the object. Note: not the URL.
The URL is redundant because we know this object is local.
object_to_store -- something to give the object when we find it.
For example, if "path" refers to someone's inbox,
"object_to_store" might be an activity to add to it.
"""
from django.urls import resolve
try:
resolved = resolve(path)
except django.urls.Resolver404:
logger.debug('%s: -- not found', path)
return None
logger.debug('%s: handled by %s, %s, %s',
path,
str(resolved.func),
str(resolved.args),
str(resolved.kwargs),
)
request = ActivityRequest(
path=path,
object_to_store=object_to_store,
)
result = resolved.func(request,
**resolved.kwargs)
if result:
logger.debug('%s: resulting in %s', path, str(result))
return result
def find_remote(url,
do_not_fetch=False,
run_delivery=False):
"""
Finds a remote object and returns it.
We return None if the object couldn't be found, or was
somehow invalid. Otherwise, we create a local copy of
the object and return that.
As a special case, if the remote object is a collection type,
we return a find.Collection (which is just a dict subclass)
containing its fields.
url -- the URL of the remote object.
do_not_fetch -- True if we should give up and return None
when the cache doesn't hold the remote object; False
(the default) if we should go and get it via HTTP.
run_delivery -- whether to deliver the found object to
its stated audiences. This is usually not what you want.
"""
from kepi.bowler_pub.models.acobject import AcObject
logger.debug('%s: find remote', url)
try:
fetch = Fetch.objects.get(
url=url,
)
# TODO: cache timeouts.
# FIXME: honour cache headers etc
# We fetched it in the past.
try:
result = AcObject.objects.get(
id = url,
)
logger.debug('%s: already fetched, and it\'s %s',
url, result)
return result
except AcObject.DoesNotExist:
logger.debug('%s: we already know it wasn\'t there',
url)
return None
except Fetch.DoesNotExist:
# We haven't fetched it before.
# So we need to fetch it now.
pass
if do_not_fetch:
logger.info('%s: do_not_fetch was set, so returning None',
url)
return None
logger.info('%s: performing the GET', url)
response = requests.get(url,
headers={'Accept': 'application/activity+json'},
)
fetch_record = Fetch(url=url)
fetch_record.save()
if response.status_code!=200:
logger.warn('%s: remote server responded %s %s' % (
url,
response.status_code, response.reason))
return None
mime_type = mimeparse.parse_mime_type(
response.headers['Content-Type'])
mime_type = '/'.join(mime_type[0:2])
if mime_type not in [
'application/activity+json',
'application/json',
'text/json',
'text/plain',
]:
logger.warn('%s: response had the wrong Content-Type, %s' % (
url, response.headers['Content-Type'],
))
return None
logger.debug('%s: response was: %s' % (
url, response.text,
))
try:
content = json.loads(response.text)
except json.JSONDecodeError:
logger.warn('%s: response was not JSON' % (
url,
))
return None
if not isinstance(content, dict):
logger.warn('%s: response was not a JSON dict' % (
url,
))
return None
content_without_at = dict([
(f, v)
for f, v in content.items()
if not f.startswith('@')
])
if content['type'] in COLLECTION_TYPES:
# It might be better if find() downloaded
# an entire Collection if it finds the index.
# At present we expect the caller to do it.
result = Collection(content_without_at)
else:
result = create(
is_local_user = False,
value = content_without_at,
id = url,
run_delivery = run_delivery,
)
return result
def is_local(url):
"""
True if "url" resides on the local server.
False otherwise. Returns False even if
the string argument is not in fact a URL.
"""
if hasattr(url, 'url'):
url = url.url
if is_short_id(url):
return True
parsed_url = urlparse(url)
return parsed_url.hostname in settings.ALLOWED_HOSTS
def _short_id_lookup(number):
"""
Helper function to find an object when we actually have
its short_id number.
"number" is the number preceded by a slash
(for example, "/2bad4dec"),
or a name preceded by an atpersat
(for example, "@alice").
"""
from kepi.bowler_pub.models import AcObject
try:
result = AcObject.objects.get(
id=number,
)
logger.debug('%s: found %s',
number, result)
return result
except AcObject.DoesNotExist:
logger.debug('%s: does not exist',
number)
return None
def find(url,
local_only=False,
do_not_fetch=False,
object_to_store=None):
"""
Finds an object.
Keyword arguments:
address -- the URL of the object.
local_only -- whether to restrict ourselves to local URLs.
object_to_store -- if the address is a local collection,
this is an object to add to that collection.
If the address is local, we pass it to find_local(),
which will look the object up using Django's usual dispatcher.
If it isn't found, we return None.
If the address is remote, we pass it to find_remote(),
which will look for the object in the cache and return it.
If it's not in the cache, and "do_not_fetch" is True,
we return None. Otherwise, we fetch the object over HTTP.
The reason for using "local_only" instead of just calling
find_local() is that this function parses URLs for you.
"""
if not url:
return None
if is_short_id(url):
return _short_id_lookup(url)
parsed_url = urlparse(url)
is_local = parsed_url.hostname in settings.ALLOWED_HOSTS
if is_local:
return find_local(parsed_url.path,
object_to_store=object_to_store)
else:
if local_only:
logger.info('find: url==%s but is_local==False; ignoring',
url)
return None
return find_remote(
url=url,
do_not_fetch=do_not_fetch)

Wyświetl plik

@ -1,26 +0,0 @@
# forms.py
#
# Part of kepi, an ActivityPub daemon.
# Copyright (c) 2018-2019 Marnanel Thurman.
# Licensed under the GNU Public License v2.
"""
This module contains some forms which are used by
the admin interface. It's not very elaborate yet.
"""
from django import forms
import kepi.bowler_pub.models as bowler_pub_models
class NoteAdminForm(forms.ModelForm):
class Meta:
model = bowler_pub_models.AcNote
fields = [
'f_content',
'f_attributedTo',
]
f_content = forms.CharField(
widget = forms.Textarea)

Wyświetl plik

@ -1,97 +0,0 @@
from django.test import TestCase
from kepi.bowler_pub.models import Audience
from kepi.bowler_pub.create import create
from . import create_local_person, REMOTE_FRED, REMOTE_JIM
class TestAudience(TestCase):
def setUp(self):
self._narcissus = create_local_person(
name = 'narcissus',
)
def test_add_audiences_for(self):
self._like = create(
f_type = 'Like',
f_actor = self._narcissus,
f_object = self._narcissus,
run_side_effects = False,
run_delivery = False,
)
a = Audience.add_audiences_for(
thing = self._like,
field = 'to',
value = [
REMOTE_FRED,
REMOTE_JIM,
],
)
results = Audience.objects.filter(
parent = self._like,
)
self.assertEqual(len(results), 2)
self.assertEqual(results[0].recipient, REMOTE_FRED)
self.assertEqual(results[1].recipient, REMOTE_JIM)
def test_create(self):
self._like = create(
f_type = 'Like',
f_actor = self._narcissus,
f_object = self._narcissus,
to = [ REMOTE_FRED, REMOTE_JIM, ],
run_side_effects = False,
run_delivery = False,
)
results = Audience.objects.filter(
parent = self._like,
)
self.assertEqual(len(results), 2)
self.assertEqual(results[0].recipient, REMOTE_FRED)
self.assertEqual(results[1].recipient, REMOTE_JIM)
def test_get_audiences_for(self):
self._like = create(
f_type = 'Like',
f_actor = self._narcissus,
f_object = self._narcissus,
run_side_effects = False,
run_delivery = False,
)
for fieldname in ['to', 'cc', 'bcc']:
a = Audience.add_audiences_for(
thing = self._like,
field = fieldname,
value = [
REMOTE_FRED,
REMOTE_JIM,
],
)
self.assertDictEqual(
Audience.get_audiences_for(self._like),
{'to': ['https://remote.example.org/users/fred',
'https://remote.example.org/users/jim'],
'cc': ['https://remote.example.org/users/fred',
'https://remote.example.org/users/jim'],
'bcc': ['https://remote.example.org/users/fred',
'https://remote.example.org/users/jim'],
})
self.assertDictEqual(
Audience.get_audiences_for(self._like,
hide_blind = True,
),
{'to': ['https://remote.example.org/users/fred',
'https://remote.example.org/users/jim'],
'cc': ['https://remote.example.org/users/fred',
'https://remote.example.org/users/jim'],
})

Wyświetl plik

@ -1,260 +0,0 @@
from django.test import TestCase, Client
from unittest import skip
from kepi.bowler_pub.models import *
import datetime
import json
from kepi.bowler_pub.find import find
from kepi.bowler_pub.utils import as_json
from . import *
import logging
logger = logging.Logger('kepi')
EXAMPLE_SERVER = 'http://testserver'
JSON_TYPE = 'application/activity+json; charset=utf-8'
PAGE_LENGTH = 50
class CollectionTests(TestCase):
def setUp(self):
settings.KEPI['LOCAL_OBJECT_HOSTNAME'] = 'testserver'
self.maxDiff = None
def check_collection(self,
path,
expectedTotalItems):
c = Client(
HTTP_ACCEPT = JSON_TYPE,
)
response = c.get(path)
if response.status_code!=200:
raise RuntimeError(response.content)
self.assertEqual(response['Content-Type'], JSON_TYPE)
result = json.loads(response.content.decode(encoding='UTF-8'))
for field in [
'@context',
'id',
'totalItems',
'type',
]:
self.assertIn(field, result)
if expectedTotalItems==0:
self.assertNotIn('first', result)
else:
self.assertIn('first', result)
self.assertEqual(result['first'], EXAMPLE_SERVER+path+'?page=1')
self.assertIn('last', result)
lastpage = 1 + int((expectedTotalItems+1)/PAGE_LENGTH)
self.assertEqual(result['last'], EXAMPLE_SERVER+path+\
('?page=%d' % (lastpage,)))
self.assertEqual(result['id'], EXAMPLE_SERVER+path)
self.assertEqual(result['totalItems'], expectedTotalItems)
self.assertEqual(result['type'], 'OrderedCollection')
def check_collection_page(self,
path,
page_number,
expectedTotalItems,
expectedOnPage,
):
def full_path(page):
if page is None:
query = ''
else:
query = '?page={}'.format(page)
return EXAMPLE_SERVER + path + query
c = Client(
HTTP_ACCEPT = JSON_TYPE,
)
response = c.get(full_path(page_number))
if response['Content-Type']=='text/html':
# let's just give up here so they have a chance
# of figuring out the error message from the server.
raise RuntimeError(response.content)
self.assertEqual(response['Content-Type'], JSON_TYPE)
result = json.loads(response.content.decode(encoding='UTF-8'))
for field in [
'@context',
'id',
'totalItems',
'type',
'partOf',
]:
self.assertIn(field, result)
self.assertEqual(result['id'], full_path(page_number))
self.assertEqual(result['totalItems'], expectedTotalItems)
self.assertEqual(result['type'], 'OrderedCollectionPage')
self.assertEqual(result['partOf'], full_path(None))
self.assertEqual(len(expectedOnPage), len(result['orderedItems']))
for actual, expected in zip(result['orderedItems'], expectedOnPage):
if type(expected)==dict:
self.assertDictContainsSubset(expected, actual)
else:
self.assertEqual(expected, actual)
if page_number!=1:
self.assertIn('prev', result)
self.assertEqual(result['prev'], full_path(page_number-1))
else:
self.assertNotIn('prev', result)
if (page_number-1)<int((expectedTotalItems-1)/PAGE_LENGTH):
self.assertIn('next', result)
self.assertEqual(result['next'], full_path(page_number+1))
else:
self.assertNotIn('next', result)
@skip("this is really slow")
def test_lots_of_entries(self):
alice = create_local_person(name='alice')
PATH = '/users/alice/outbox'
NUMBER_OF_PASSES = 100
statuses = []
for i in range(0, 103):
logger.info(' =================== test_lots_of_entries, pass %d / %d',
i, NUMBER_OF_PASSES)
logger.info(' == Index?')
self.check_collection(
path=PATH,
expectedTotalItems=len(statuses),
)
lastpage = int((len(statuses))/PAGE_LENGTH)
# nb that "page" here is 0-based, but
# ActivityPub sees it as 1-based
for page in range(lastpage+1):
logger.info(' == Page %d/%d?', page, lastpage)
if page==lastpage:
expecting = statuses[page*PAGE_LENGTH:]
else:
expecting = statuses[page*PAGE_LENGTH:((page+1)*PAGE_LENGTH)]
expecting = [json.loads(x) for x in expecting]
self.check_collection_page(
path=PATH,
page_number=page+1,
expectedTotalItems=len(statuses),
expectedOnPage=expecting,
)
statuses.append(as_json(create_local_note(
attributedTo = alice,
content = 'Status %d' % (i,),
).activity_form))
def test_usageByOtherApps(self):
PATH = '/users'
EXPECTED_SERIALIZATION = [
{'id': 'https://testserver/users/alice', 'name': 'alice', 'type': 'Person', },
{'id': 'https://testserver/users/bob', 'name': 'bob', 'type': 'Person', },
{'id': 'https://testserver/users/carol', 'name': 'carol', 'type': 'Person', },
]
users = [
create_local_person(name='alice'),
create_local_person(name='bob'),
create_local_person(name='carol'),
]
for user in users:
user.save()
self.check_collection(
path=PATH,
expectedTotalItems=len(users),
)
self.check_collection_page(
path=PATH,
page_number=1,
expectedTotalItems=len(users),
expectedOnPage=EXPECTED_SERIALIZATION,
)
def test_followers_and_following(self):
people = {}
for name in ['alice', 'bob', 'carol']:
people[name] = create_local_person(name = name)
follow = create(
f_type = 'Follow',
actor = people[name],
f_object = people['alice'],
)
create(
f_type = 'Accept',
actor = people['alice'],
f_object = follow,
)
path = '/users/alice/followers'
self.check_collection(
path=path,
expectedTotalItems=3,
)
self.check_collection_page(
path=path,
page_number=1,
expectedTotalItems=3,
expectedOnPage=[
'https://testserver/users/alice',
'https://testserver/users/bob',
'https://testserver/users/carol',
],
)
for name in ['alice', 'bob', 'carol']:
path='/users/{}/following'.format(name)
self.check_collection(
path=path,
expectedTotalItems=1,
)
self.check_collection_page(
path=path,
page_number=1,
expectedTotalItems=1,
expectedOnPage=[
'https://testserver/users/alice',
],
)

Wyświetl plik

@ -1,96 +0,0 @@
from django.test import TestCase
from kepi.bowler_pub.find import find
from kepi.bowler_pub.create import create
from django.conf import settings
from . import *
import httpretty
import json
import logging
from kepi.bowler_pub.utils import as_json
logger = logging.getLogger(name='kepi')
REMOTE_URL = 'https://remote.example.net/fnord'
STUFF = {
"@context": "https://www.w3.org/ns/activitystreams",
"id": REMOTE_URL,
"type": "Note",
"to": ["https://testserver/someone"],
"attributedTo": "https://europa.example.org/someone-else",
"content": "I've got a lovely bunch of coconuts.",
}
class TestFind(TestCase):
def setUp(self):
settings.KEPI['LOCAL_OBJECT_HOSTNAME'] = 'testserver'
def _mock_remote_stuff(self):
mock_remote_object(
REMOTE_URL,
content = as_json(STUFF),
)
@httpretty.activate
def test_find_remote(self):
self._mock_remote_stuff()
found = find(REMOTE_URL)
self.assertEqual(
found.url,
REMOTE_URL)
self.assertFalse(
found.is_local,
)
self.assertDictContainsSubset(
{
'attributedTo': 'https://europa.example.org/someone-else',
'id': 'https://remote.example.net/fnord',
'to': ['https://testserver/someone'],
'type': 'Note'},
found.activity_form,
)
@httpretty.activate
def test_find_remote_404(self):
mock_remote_object(
REMOTE_URL,
content = '',
)
found = find(REMOTE_URL)
self.assertIsNone(found)
def test_find_local(self):
a = create(
f_actor = 'https://example.net/users/fred',
f_object = 'https://example.net/articles/i-like-jam',
f_type = 'Like',
)
a.save()
found = find(a.url)
self.assertDictEqual(
found.activity_form,
a.activity_form,
)
def test_find_local_404(self):
found = find(configured_url('OBJECT_LINK',
number = 'walrus',
))
self.assertIsNone(
found,
)

Wyświetl plik

@ -1,388 +0,0 @@
from django.test import TestCase
from unittest import skip
from kepi.bowler_pub.tests import *
from kepi.bowler_pub.create import create
from kepi.bowler_pub.models.audience import Audience, AUDIENCE_FIELD_NAMES
from kepi.bowler_pub.models.mention import Mention
from kepi.bowler_pub.models.item import AcItem
from kepi.bowler_pub.models.acobject import AcObject
from kepi.bowler_pub.models.activity import AcActivity
from django.test import Client
from urllib.parse import urlparse
import httpretty
import logging
import json
REMOTE_DAVE_ID = "https://dave.example.net/users/dave"
REMOTE_DAVE_DOMAIN = urlparse(REMOTE_DAVE_ID).netloc
REMOTE_DAVE_FOLLOWERS = REMOTE_DAVE_ID + 'followers'
REMOTE_DAVE_KEY = REMOTE_DAVE_ID + '#main-key'
ALICE_ID = 'https://testserver/users/alice'
OUTBOX = ALICE_ID+'/outbox'
OUTBOX_PATH = '/users/alice/outbox'
BOB_ID = 'https://testserver/users/bob'
MIME_TYPE = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'
# as given in https://www.w3.org/TR/activitypub/
OBJECT_FORM = {
"@context": ["https://www.w3.org/ns/activitystreams",
{"@language": "en"}],
"type": "Note",
'attributedTo': ALICE_ID,
"name": "Chris liked 'Minimal ActivityPub update client'",
"object": "https://example.net/2016/05/minimal-activitypub",
"to": [PUBLIC],
}
CREATE_FORM = {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': ALICE_ID + '#foo',
'actor': ALICE_ID,
'type': 'Create',
'object': OBJECT_FORM,
}
logger = logging.getLogger(name='kepi')
class TestOutbox(TestCase):
def setUp(self):
settings.KEPI['LOCAL_OBJECT_HOSTNAME'] = 'testserver'
settings.ALLOWED_HOSTS = [
'altair.example.com',
'testserver',
]
def _send(self,
content,
keys = None,
sender = None,
signed = True,
):
if keys is None:
keys = json.load(open('kepi/bowler_pub/tests/keys/keys-0001.json', 'r'))
if sender is None:
sender = create_local_person(
name = 'alice',
publicKey = keys['public'],
privateKey = keys['private'],
)
f_body = dict([('f_'+f,v) for f,v in content.items()])
body, headers = test_message_body_and_headers(
secret = keys['private'],
path = OUTBOX_PATH,
key_id = sender['name']+'#main-key',
signed = signed,
**f_body,
)
headers=dict([('HTTP_'+f,v) for f,v in headers.items()])
c = Client()
response = c.post(OUTBOX,
body,
**headers,
content_type='application/activity+json',
)
return response
def _get(self,
client,
url):
response = client.get(url,
HTTP_ACCEPT = MIME_TYPE,
)
self.assertEqual(
response.status_code,
200)
return json.loads(
str(response.content, encoding='UTF-8'))
def _get_collection(self, url):
c = Client()
result = []
linkname = 'first'
while True:
page = self._get(c, url)
logger.debug('Received %s:', url)
logger.debug(' -- %s', page)
if 'orderedAcItems' in page:
result.extend(page['orderedAcItems'])
if linkname not in page:
logger.info('Inbox contains: %s',
result)
return result
url = page[linkname]
linkname = 'next'
def test_no_signature(self):
self._send(
content = CREATE_FORM,
signed = False,
)
statuses = AcItem.objects.filter(
f_attributedTo=ALICE_ID,
)
self.assertEqual(
len(statuses),
0)
def test_create(self):
self._send(
content = CREATE_FORM,
)
statuses = AcItem.objects.filter(
f_attributedTo=ALICE_ID,
)
something = self._get_collection(OUTBOX)
self.assertEqual(
len(statuses),
1)
def test_post_by_remote_interloper(self):
keys = json.load(open('kepi/bowler_pub/tests/keys/keys-0002.json', 'r'))
sender = create_remote_person(
url = REMOTE_DAVE_ID,
name = 'dave',
publicKey = keys['public'],
)
create = CREATE_FORM
create['actor'] = REMOTE_DAVE_ID
create['id'] = REMOTE_DAVE_ID+'#foo'
self._send(
content = create,
sender = sender,
)
statuses = AcItem.objects.filter(
f_attributedTo=REMOTE_DAVE_ID,
)
self.assertEqual(
len(statuses),
0)
def test_post_by_local_interloper(self):
keys1 = json.load(open('kepi/bowler_pub/tests/keys/keys-0001.json', 'r'))
keys2 = json.load(open('kepi/bowler_pub/tests/keys/keys-0002.json', 'r'))
create_local_person(
name = 'alice',
privateKey = keys1['private'],
publicKey = keys1['public'],
)
sender = create_local_person(
name = 'bob',
privateKey = keys2['private'],
publicKey = keys2['public'],
)
create = CREATE_FORM
create['actor'] = sender.url
create['id'] = sender.url+'#foo'
self._send(
content = create,
sender = sender,
)
statuses = AcItem.objects.filter(
f_attributedTo=sender.id,
)
self.assertEqual(
len(statuses),
0)
@httpretty.activate
def test_unwrapped_object(self):
items_before = list(AcObject.objects.all())
self._send(
content = OBJECT_FORM,
)
items_after = list(AcObject.objects.all())
# This should have created two objects:
# the Note we sent, and an implict Create.
self.assertEqual(
len(items_after)-len(items_before),
2)
def test_create_doesnt_work_on_activities(self):
create = CREATE_FORM
create['object']['type'] = 'Like'
self._send(
content = create,
)
activities = AcActivity.objects.all()
self.assertEqual(
len(activities),
0)
def test_like(self):
note = create_local_note(
attributedTo = BOB_ID,
)
self._send(
content = {
'@context': 'https://www.w3.org/ns/activitystreams',
'actor': ALICE_ID,
'type': 'Like',
'object': note.url,
}
)
self.assertEqual(
len(AcActivity.objects.filter(f_actor=ALICE_ID)),
1)
# TODO When AcActors have liked() and AcObjects have likes(),
# test those here too.
def test_update(self):
note = create_local_note(
attributedTo = ALICE_ID,
content = 'Twas brillig, and the slithy toves',
)
self.assertEqual(
note['content'],
'Twas brillig, and the slithy toves',
)
self._send(
content = {
'@context': 'https://www.w3.org/ns/activitystreams',
'actor': ALICE_ID,
'type': 'Update',
'object': {
'id': note.url,
'content': 'did gyre and gimble in the wabe.',
},
}
)
note = AcItem.objects.get(f_attributedTo = ALICE_ID)
self.assertEqual(
note['content'],
'did gyre and gimble in the wabe.',
)
def test_update_someone_elses(self):
note = create_local_note(
attributedTo = BOB_ID,
content = 'Twas brillig, and the slithy toves',
)
self.assertEqual(
note['content'],
'Twas brillig, and the slithy toves',
)
self._send(
content = {
'@context': 'https://www.w3.org/ns/activitystreams',
'actor': ALICE_ID,
'type': 'Update',
'object': {
'id': note.url,
'content': 'did gyre and gimble in the wabe.',
},
}
)
note = AcItem.objects.get(f_attributedTo = BOB_ID)
# no change, because Alice doesn't own this note
self.assertEqual(
note['content'],
'Twas brillig, and the slithy toves',
)
def test_delete(self):
c = Client()
keys = json.load(open('kepi/bowler_pub/tests/keys/keys-0001.json', 'r'))
alice = create_local_person(
name = 'alice',
publicKey = keys['public'],
privateKey = keys['private'],
)
for tombstones, result_code in [
(True, 410),
(False, 404),
]:
settings.KEPI['TOMBSTONES'] = tombstones
note = create_local_note(
attributedTo = ALICE_ID,
content = 'Twas brillig, and the slithy toves',
)
response = c.get(note.url)
self.assertEqual(
response.status_code,
200)
self._send(
keys = keys,
sender = alice,
content = {
'@context': 'https://www.w3.org/ns/activitystreams',
'actor': ALICE_ID,
'type': 'Delete',
'object': note.url,
},
)
response = c.get(note.url)
self.assertEqual(
response.status_code,
result_code)

Wyświetl plik

@ -1,5 +1,6 @@
from django.core.serializers.json import DjangoJSONEncoder
from django.conf import settings
from urllib.parse import urlparse
def as_json(d,
indent=2):
@ -70,4 +71,15 @@ def short_id_to_url(v):
number = v[1:],
)
def is_local(url):
"""
True if "url" resides on the local server.
False otherwise. Returns False even if
the string argument is not in fact a URL.
"""
if hasattr(url, 'url'):
url = url.url
parsed_url = urlparse(url)
return parsed_url.hostname in settings.ALLOWED_HOSTS

Wyświetl plik

@ -15,8 +15,7 @@ place to go.
from kepi.bowler_pub import ATSIGN_CONTEXT
import kepi.bowler_pub.validation
from kepi.bowler_pub.find import find, is_local
from kepi.bowler_pub.utils import configured_url, short_id_to_url, uri_to_url
from kepi.bowler_pub.utils import configured_url, short_id_to_url, uri_to_url, is_local
from django.shortcuts import render, get_object_or_404
import django.views
from django.http import HttpResponse, JsonResponse, Http404

Wyświetl plik

@ -92,6 +92,7 @@ INSTALLED_APPS = (
'kepi.busby_1st',
'kepi.bowler_pub',
'kepi.sombrero_sendpub',
'kepi.trilby_api',
)

Wyświetl plik

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

Wyświetl plik

@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

Wyświetl plik

@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

Wyświetl plik

@ -3,7 +3,6 @@ from django.contrib.auth.admin import UserAdmin
from django.contrib.auth.forms import UserChangeForm, UserCreationForm
import kepi.trilby_api.forms as trilby_forms
import kepi.trilby_api.models as trilby_models
from kepi.bowler_pub.create import create
@admin.register(trilby_models.Person)
class PersonAdmin(admin.ModelAdmin):

Wyświetl plik

@ -2,7 +2,6 @@ from django.db import models
from django.db.models.constraints import UniqueConstraint
from django.contrib.auth.models import AbstractUser
from django.conf import settings
from kepi.bowler_pub.create import create
import kepi.bowler_pub.crypto as crypto
from kepi.bowler_pub.utils import uri_to_url
from django.utils.timezone import now

Wyświetl plik

@ -2,7 +2,6 @@ from django.db import models
from django.db.models.constraints import UniqueConstraint
from django.contrib.auth.models import AbstractUser
from django.conf import settings
from kepi.bowler_pub.create import create
import kepi.bowler_pub.crypto as crypto
from kepi.bowler_pub.utils import uri_to_url
from django.utils.timezone import now

Wyświetl plik

@ -2,7 +2,6 @@ from django.db import models
from django.db.models.constraints import UniqueConstraint
from django.contrib.auth.models import AbstractUser
from django.conf import settings
from kepi.bowler_pub.create import create
import kepi.bowler_pub.crypto as crypto
from kepi.bowler_pub.utils import uri_to_url
from django.utils.timezone import now

Wyświetl plik

@ -2,7 +2,6 @@ from django.db import models
from django.db.models.constraints import UniqueConstraint
from django.contrib.auth.models import AbstractUser
from django.conf import settings
from kepi.bowler_pub.create import create
import kepi.bowler_pub.crypto as crypto
from kepi.bowler_pub.utils import uri_to_url
import kepi.trilby_api.utils as trilby_utils

Wyświetl plik

@ -2,7 +2,6 @@ from django.db import models
from django.db.models.constraints import UniqueConstraint
from django.contrib.auth.models import AbstractUser
from django.conf import settings
from kepi.bowler_pub.create import create
import kepi.bowler_pub.crypto as crypto
from kepi.bowler_pub.utils import uri_to_url
import kepi.trilby_api.utils as trilby_utils