Use dataclasses to define activitypub (de)serialization (#177)

* Use dataclasses to define activitypub (de)serialization
This commit is contained in:
Mouse Reeve 2020-09-17 13:02:52 -07:00 committed by GitHub
parent 2c0a07a330
commit 8bbf1fe252
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
46 changed files with 1449 additions and 1228 deletions

View file

@ -1,16 +1,19 @@
''' bring activitypub functions into the namespace ''' ''' bring activitypub functions into the namespace '''
from .actor import get_actor import inspect
from .book import get_book, get_author, get_shelf import sys
from .create import get_create, get_update
from .follow import get_following, get_followers from .base_activity import ActivityEncoder, Image, PublicKey, Signature
from .follow import get_follow_request, get_unfollow, get_accept, get_reject from .note import Note, Article, Comment, Review, Quotation
from .outbox import get_outbox, get_outbox_page from .interaction import Boost, Like
from .shelve import get_add, get_remove from .ordered_collection import OrderedCollection, OrderedCollectionPage
from .status import get_review, get_review_article from .person import Person
from .status import get_rating, get_rating_note from .book import Edition, Work, Author
from .status import get_comment, get_comment_article from .verbs import Create, Undo, Update
from .status import get_quotation, get_quotation_article from .verbs import Follow, Accept, Reject
from .status import get_status, get_replies, get_replies_page from .verbs import Add, Remove
from .status import get_favorite, get_unfavorite
from .status import get_boost # this creates a list of all the Activity types that we can serialize,
from .status import get_add_tag, get_remove_tag # so when an Activity comes in from outside, we can check if it's known
cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass)
activity_objects = {c[0]: c[1] for c in cls_members \
if hasattr(c[1], 'to_model')}

View file

@ -1,51 +0,0 @@
''' actor serializer '''
from fedireads.settings import DOMAIN
def get_actor(user):
''' activitypub actor from db User '''
avatar = user.avatar
icon_path = '/static/images/default_avi.jpg'
icon_type = 'image/jpeg'
if avatar:
icon_path = avatar.url
icon_type = 'image/%s' % icon_path.split('.')[-1]
icon_url = 'https://%s%s' % (DOMAIN, icon_path)
return {
'@context': [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
{
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
"schema": "http://schema.org#",
"PropertyValue": "schema:PropertyValue",
"value": "schema:value",
},
],
'id': user.remote_id,
'type': 'Person',
'preferredUsername': user.localname,
'name': user.name,
'inbox': user.inbox,
'outbox': '%s/outbox' % user.remote_id,
'followers': '%s/followers' % user.remote_id,
'following': '%s/following' % user.remote_id,
'summary': user.summary,
'publicKey': {
'id': '%s/#main-key' % user.remote_id,
'owner': user.remote_id,
'publicKeyPem': user.public_key,
},
'endpoints': {
'sharedInbox': user.shared_inbox,
},
'fedireadsUser': True,
'manuallyApprovesFollowers': user.manually_approves_followers,
"icon": {
"type": "Image",
"mediaType": icon_type,
"url": icon_url,
},
}

View file

@ -0,0 +1,118 @@
''' basics for an activitypub serializer '''
from dataclasses import dataclass, fields, MISSING
from json import JSONEncoder
from django.db.models.fields.related_descriptors \
import ForwardManyToOneDescriptor
class ActivityEncoder(JSONEncoder):
''' used to convert an Activity object into json '''
def default(self, o):
return o.__dict__
@dataclass
class Image:
''' image block '''
mediaType: str
url: str
type: str = 'Image'
@dataclass
class PublicKey:
''' public key block '''
id: str
owner: str
publicKeyPem: str
@dataclass
class Signature:
''' public key block '''
creator: str
created: str
signatureValue: str
type: str = 'RsaSignature2017'
@dataclass(init=False)
class ActivityObject:
''' actor activitypub json '''
id: str
type: str
def __init__(self, **kwargs):
''' this lets you pass in an object with fields
that aren't in the dataclass, which it ignores.
Any field in the dataclass is required or has a
default value '''
for field in fields(self):
try:
value = kwargs[field.name]
except KeyError:
if field.default == MISSING:
raise TypeError('Missing required field: %s' % field.name)
value = field.default
setattr(self, field.name, value)
def to_model(self, model, instance=None):
''' convert from an activity to a model '''
if not isinstance(self, model.activity_serializer):
raise TypeError('Wrong activity type for model')
model_fields = [m.name for m in model._meta.get_fields()]
mapped_fields = {}
for mapping in model.activity_mappings:
if mapping.model_key not in model_fields:
continue
# value is None if there's a default that isn't supplied
# in the activity but is supplied in the formatter
value = None
if mapping.activity_key:
value = getattr(self, mapping.activity_key)
model_field = getattr(model, mapping.model_key)
# remote_id -> foreign key resolver
if isinstance(model_field, ForwardManyToOneDescriptor) and value:
fk_model = model_field.field.related_model
value = resolve_foreign_key(fk_model, value)
mapped_fields[mapping.model_key] = mapping.model_formatter(value)
# updating an existing model isntance
if instance:
for k, v in mapped_fields.items():
setattr(instance, k, v)
instance.save()
return instance
# creating a new model instance
return model.objects.create(**mapped_fields)
def serialize(self):
''' convert to dictionary with context attr '''
data = self.__dict__
data['@context'] = 'https://www.w3.org/ns/activitystreams'
return data
def resolve_foreign_key(model, remote_id):
''' look up the remote_id on an activity json field '''
result = model.objects
if hasattr(model.objects, 'select_subclasses'):
result = result.select_subclasses()
result = result.filter(
remote_id=remote_id
).first()
if not result:
raise ValueError('Could not resolve remote_id in %s model: %s' % \
(model.__name__, remote_id))
return result

View file

@ -1,127 +1,67 @@
''' federate book data ''' ''' book and author data '''
from fedireads.settings import DOMAIN from dataclasses import dataclass, field
from typing import List
def get_book(book, recursive=True): from .base_activity import ActivityObject, Image
''' activitypub serialize a book '''
fields = [ @dataclass(init=False)
'title', class Book(ActivityObject):
'sort_title', ''' serializes an edition or work, abstract '''
'subtitle', authors: List[str]
'isbn_13', first_published_date: str
'oclc_number', published_date: str
'openlibrary_key',
'librarything_key',
'lccn',
'oclc_number',
'pages',
'physical_format',
'misc_identifiers',
'description', title: str
'languages', sort_title: str
'series', subtitle: str
'series_number', description: str
'subjects', languages: List[str]
'subject_places', series: str
'pages', series_number: str
'physical_format', subjects: List[str]
] subject_places: List[str]
book_type = type(book).__name__ openlibrary_key: str
activity = { librarything_key: str
'@context': 'https://www.w3.org/ns/activitystreams', goodreads_key: str
'type': 'Document',
'book_type': book_type,
'name': book.title,
'url': book.local_id,
'authors': [a.local_id for a in book.authors.all()], attachment: List[Image] = field(default=lambda: [])
'first_published_date': book.first_published_date.isoformat() if \ type: str = 'Book'
book.first_published_date else None,
'published_date': book.published_date.isoformat() if \
book.published_date else None,
}
if recursive:
if book_type == 'Edition':
activity['work'] = get_book(book.parent_work, recursive=False)
else:
editions = book.edition_set.order_by('default')
activity['editions'] = [
get_book(b, recursive=False) for b in editions]
for field in fields:
if hasattr(book, field):
activity[field] = book.__getattribute__(field)
if book.cover:
image_path = book.cover.url
image_type = image_path.split('.')[-1]
activity['attachment'] = [{
'type': 'Document',
'mediaType': 'image/%s' % image_type,
'url': 'https://%s%s' % (DOMAIN, image_path),
'name': 'Cover of "%s"' % book.title,
}]
return {k: v for (k, v) in activity.items() if v}
def get_author(author): @dataclass(init=False)
''' serialize an author ''' class Edition(Book):
fields = [ ''' Edition instance of a book object '''
'name', isbn_10: str
'born', isbn_13: str
'died', oclc_number: str
'aliases', asin: str
'bio' pages: str
'openlibrary_key', physical_format: str
'wikipedia_link', publishers: List[str]
]
activity = { work: str
'@context': 'https://www.w3.org/ns/activitystreams', type: str = 'Edition'
'url': author.local_id,
'type': 'Person',
}
for field in fields:
if hasattr(author, field):
activity[field] = author.__getattribute__(field)
return activity
def get_shelf(shelf, page=None): @dataclass(init=False)
''' serialize shelf object ''' class Work(Book):
id_slug = shelf.remote_id ''' work instance of a book object '''
if page: lccn: str
return get_shelf_page(shelf, page) editions: List[str]
count = shelf.books.count() type: str = 'Work'
return {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': id_slug,
'type': 'OrderedCollection',
'totalItems': count,
'first': '%s?page=1' % id_slug,
}
def get_shelf_page(shelf, page):
''' list of books on a shelf ''' @dataclass(init=False)
page = int(page) class Author(ActivityObject):
page_length = 10 ''' author of a book '''
start = (page - 1) * page_length url: str
end = start + page_length name: str
shelf_page = shelf.books.all()[start:end] born: str
id_slug = shelf.local_id died: str
data = { aliases: str
'@context': 'https://www.w3.org/ns/activitystreams', bio: str
'id': '%s?page=%d' % (id_slug, page), openlibrary_key: str
'type': 'OrderedCollectionPage', wikipedia_link: str
'totalItems': shelf.books.count(), type: str = 'Person'
'partOf': id_slug,
'orderedItems': [get_book(b) for b in shelf_page],
}
if end <= shelf.books.count():
# there are still more pages
data['next'] = '%s?page=%d' % (id_slug, page + 1)
if start > 0:
data['prev'] = '%s?page=%d' % (id_slug, page - 1)
return data

View file

@ -1,46 +0,0 @@
''' format Create activities and sign them '''
from base64 import b64encode
from Crypto.PublicKey import RSA
from Crypto.Signature import pkcs1_15
from Crypto.Hash import SHA256
def get_create(user, status_json):
''' create activitypub json for a Create activity '''
signer = pkcs1_15.new(RSA.import_key(user.private_key))
content = status_json['content']
signed_message = signer.sign(SHA256.new(content.encode('utf8')))
return {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': '%s/activity' % status_json['id'],
'type': 'Create',
'actor': user.remote_id,
'published': status_json['published'],
'to': ['%s/followers' % user.remote_id],
'cc': ['https://www.w3.org/ns/activitystreams#Public'],
'object': status_json,
'signature': {
'type': 'RsaSignature2017',
'creator': '%s#main-key' % user.remote_id,
'created': status_json['published'],
'signatureValue': b64encode(signed_message).decode('utf8'),
}
}
def get_update(user, activity_json):
''' a user profile or book or whatever got updated '''
# TODO: should this have a signature??
return {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': 'https://friend.camp/users/tripofmice#updates/1585446332',
'type': 'Update',
'actor': user.remote_id,
'to': [
'https://www.w3.org/ns/activitystreams#Public'
],
'object': activity_json,
}

View file

@ -1,113 +0,0 @@
''' makin' freinds inthe ap json format '''
from uuid import uuid4
from fedireads.settings import DOMAIN
def get_follow_request(user, to_follow):
''' a local user wants to follow someone '''
uuid = uuid4()
return {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': 'https://%s/%s' % (DOMAIN, str(uuid)),
'summary': '',
'type': 'Follow',
'actor': user.remote_id,
'object': to_follow.remote_id,
}
def get_unfollow(relationship):
''' undo that precious bond of friendship '''
return {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': '%s/undo' % relationship.remote_id,
'type': 'Undo',
'actor': relationship.user_subject.remote_id,
'object': {
'id': relationship.relationship_id,
'type': 'Follow',
'actor': relationship.user_subject.remote_id,
'object': relationship.user_object.remote_id,
}
}
def get_accept(user, relationship):
''' accept a follow request '''
return {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': '%s#accepts/follows/' % user.remote_id,
'type': 'Accept',
'actor': user.remote_id,
'object': {
'id': relationship.relationship_id,
'type': 'Follow',
'actor': relationship.user_subject.remote_id,
'object': relationship.user_object.remote_id,
}
}
def get_reject(user, relationship):
''' reject a follow request '''
return {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': '%s#rejects/follows/' % user.remote_id,
'type': 'Reject',
'actor': user.remote_id,
'object': {
'id': relationship.relationship_id,
'type': 'Follow',
'actor': relationship.user_subject.remote_id,
'object': relationship.user_object.remote_id,
}
}
def get_followers(user, page, follow_queryset):
''' list of people who follow a user '''
id_slug = '%s/followers' % user.remote_id
return get_follow_info(id_slug, page, follow_queryset)
def get_following(user, page, follow_queryset):
''' list of people who follow a user '''
id_slug = '%s/following' % user.remote_id
return get_follow_info(id_slug, page, follow_queryset)
def get_follow_info(id_slug, page, follow_queryset):
''' a list of followers or following '''
if page:
return get_follow_page(follow_queryset, id_slug, page)
count = follow_queryset.count()
return {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': id_slug,
'type': 'OrderedCollection',
'totalItems': count,
'first': '%s?page=1' % id_slug,
}
def get_follow_page(user_list, id_slug, page):
''' format a list of followers/following '''
page = int(page)
page_length = 10
start = (page - 1) * page_length
end = start + page_length
follower_page = user_list.all()[start:end]
data = {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': '%s?page=%d' % (id_slug, page),
'type': 'OrderedCollectionPage',
'totalItems': user_list.count(),
'partOf': id_slug,
'orderedItems': [u.remote_id for u in follower_page],
}
if end <= user_list.count():
# there are still more pages
data['next'] = '%s?page=%d' % (id_slug, page + 1)
if start > 0:
data['prev'] = '%s?page=%d' % (id_slug, page - 1)
return data

View file

@ -0,0 +1,20 @@
''' boosting and liking posts '''
from dataclasses import dataclass
from .base_activity import ActivityObject
@dataclass(init=False)
class Like(ActivityObject):
''' a user faving an object '''
actor: str
object: str
type: str = 'Like'
@dataclass(init=False)
class Boost(ActivityObject):
''' boosting a status '''
actor: str
object: str
type: str = 'Announce'

View file

@ -0,0 +1,50 @@
''' note serializer and children thereof '''
from dataclasses import dataclass, field
from typing import Dict, List
from .base_activity import ActivityObject, Image
@dataclass(init=False)
class Note(ActivityObject):
''' Note activity '''
url: str
inReplyTo: str
published: str
attributedTo: str
to: List[str]
cc: List[str]
content: str
replies: Dict
# TODO: this is wrong???
attachment: List[Image] = field(default=lambda: [])
sensitive: bool = False
type: str = 'Note'
@dataclass(init=False)
class Article(Note):
''' what's an article except a note with more fields '''
name: str
type: str = 'Article'
@dataclass(init=False)
class Comment(Note):
''' like a note but with a book '''
inReplyToBook: str
type: str = 'Comment'
@dataclass(init=False)
class Review(Comment):
''' a full book review '''
name: str
rating: int
type: str = 'Review'
@dataclass(init=False)
class Quotation(Comment):
''' a quote and commentary on a book '''
quote: str
type: str = 'Quotation'

View file

@ -0,0 +1,25 @@
''' defines activitypub collections (lists) '''
from dataclasses import dataclass
from typing import List
from .base_activity import ActivityObject
@dataclass(init=False)
class OrderedCollection(ActivityObject):
''' structure of an ordered collection activity '''
totalItems: int
first: str
last: str = ''
name: str = ''
type: str = 'OrderedCollection'
@dataclass(init=False)
class OrderedCollectionPage(ActivityObject):
''' structure of an ordered collection activity '''
partOf: str
orderedItems: List
next: str
prev: str
type: str = 'OrderedCollectionPage'

View file

@ -1,43 +0,0 @@
''' activitypub json for collections '''
from urllib.parse import urlencode
from .status import get_status, get_review
def get_outbox(user, size):
''' helper function for creating an outbox '''
return {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': user.outbox,
'type': 'OrderedCollection',
'totalItems': size,
'first': '%s?page=true' % user.outbox,
'last': '%s?min_id=0&page=true' % user.outbox
}
def get_outbox_page(user, page_id, statuses, max_id, min_id):
''' helper for formatting outbox pages '''
# not generalizing this more because the format varies for some reason
page = {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': page_id,
'type': 'OrderedCollectionPage',
'partOf': user.outbox,
'orderedItems': [],
}
for status in statuses:
if status.status_type == 'Review':
status_activity = get_review(status)
else:
status_activity = get_status(status)
page['orderedItems'].append(status_activity)
if max_id:
page['next'] = user.outbox + '?' + \
urlencode({'min_id': max_id, 'page': 'true'})
if min_id:
page['prev'] = user.outbox + '?' + \
urlencode({'max_id': min_id, 'page': 'true'})
return page

View file

@ -0,0 +1,22 @@
''' actor serializer '''
from dataclasses import dataclass, field
from typing import Dict
from .base_activity import ActivityObject, Image, PublicKey
@dataclass(init=False)
class Person(ActivityObject):
''' actor activitypub json '''
preferredUsername: str
name: str
inbox: str
outbox: str
followers: str
summary: str
publicKey: PublicKey
endpoints: Dict
icon: Image = field(default=lambda: {})
fedireadsUser: str = False
manuallyApprovesFollowers: str = False
discoverable: str = True
type: str = 'Person'

View file

@ -1,32 +0,0 @@
''' activitypub json for collections '''
from uuid import uuid4
def get_add(*args):
''' activitypub Add activity '''
return get_add_remove(*args, action='Add')
def get_remove(*args):
''' activitypub Add activity '''
return get_add_remove(*args, action='Remove')
def get_add_remove(user, book, shelf, action='Add'):
''' format a shelve book json blob '''
uuid = uuid4()
return {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': str(uuid),
'type': action,
'actor': user.remote_id,
'object': {
'type': 'Document',
'name': book.title,
'url': book.local_id,
},
'target': {
'type': 'Collection',
'name': shelf.name,
'id': shelf.remote_id,
}
}

View file

@ -1,254 +0,0 @@
''' status serializers '''
from uuid import uuid4
from fedireads.settings import DOMAIN
def get_rating(review):
''' activitypub serialize rating activity '''
status = get_status(review)
status['inReplyToBook'] = review.book.local_id
status['fedireadsType'] = review.status_type
status['rating'] = review.rating
status['content'] = '%d star rating of "%s"' % (
review.rating, review.book.title)
return status
def get_quotation(quotation):
''' fedireads json for quotations '''
status = get_status(quotation)
status['inReplyToBook'] = quotation.book.local_id
status['fedireadsType'] = quotation.status_type
status['quote'] = quotation.quote
return status
def get_quotation_article(quotation):
''' a book quotation formatted for a non-fedireads isntance (mastodon) '''
status = get_status(quotation)
content = '"%s"<br>-- <a href="%s">"%s"</a>)<br><br>%s' % (
quotation.quote,
quotation.book.local_id,
quotation.book.title,
quotation.content,
)
status['content'] = content
return status
def get_review(review):
''' fedireads json for book reviews '''
status = get_status(review)
status['inReplyToBook'] = review.book.local_id
status['fedireadsType'] = review.status_type
status['name'] = review.name
status['rating'] = review.rating
return status
def get_comment(comment):
''' fedireads json for book reviews '''
status = get_status(comment)
status['inReplyToBook'] = comment.book.local_id
status['fedireadsType'] = comment.status_type
return status
def get_rating_note(review):
''' simple rating, send it as a note not an artciel '''
status = get_status(review)
status['content'] = 'Rated "%s": %d stars' % (
review.book.title,
review.rating,
)
status['type'] = 'Note'
return status
def get_review_article(review):
''' a book review formatted for a non-fedireads isntance (mastodon) '''
status = get_status(review)
if review.rating:
status['name'] = 'Review of "%s" (%d stars): %s' % (
review.book.title,
review.rating,
review.name
)
else:
status['name'] = 'Review of "%s": %s' % (
review.book.title,
review.name
)
return status
def get_comment_article(comment):
''' a book comment formatted for a non-fedireads isntance (mastodon) '''
status = get_status(comment)
status['content'] += '<br><br>(comment on <a href="%s">"%s"</a>)' % \
(comment.book.local_id, comment.book.title)
return status
def get_status(status):
''' create activitypub json for a status '''
user = status.user
uri = status.remote_id
reply_parent_id = status.reply_parent.remote_id \
if status.reply_parent else None
image_attachments = []
books = list(status.mention_books.all()[:3])
if hasattr(status, 'book'):
books.append(status.book)
for book in books:
if book and book.cover:
image_path = book.cover.url
image_type = image_path.split('.')[-1]
image_attachments.append({
'type': 'Document',
'mediaType': 'image/%s' % image_type,
'url': 'https://%s%s' % (DOMAIN, image_path),
'name': 'Cover of "%s"' % book.title,
})
status_json = {
'id': uri,
'url': uri,
'inReplyTo': reply_parent_id,
'published': status.published_date.isoformat(),
'attributedTo': user.remote_id,
# TODO: assuming all posts are public -- should check privacy db field
'to': ['https://www.w3.org/ns/activitystreams#Public'],
'cc': ['%s/followers' % user.remote_id],
'sensitive': status.sensitive,
'content': status.content,
'type': status.activity_type,
'attachment': image_attachments,
'replies': {
'id': '%s/replies' % uri,
'type': 'Collection',
'first': {
'type': 'CollectionPage',
'next': '%s/replies?only_other_accounts=true&page=true' % uri,
'partOf': '%s/replies' % uri,
'items': [], # TODO: populate with replies
}
}
}
return status_json
def get_replies(status, replies):
''' collection of replies '''
id_slug = status.remote_id + '/replies'
return {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': id_slug,
'type': 'Collection',
'first': {
'id': '%s?page=true' % id_slug,
'type': 'CollectionPage',
'next': '%s?only_other_accounts=true&page=true' % id_slug,
'partOf': id_slug,
'items': [get_status(r) for r in replies],
}
}
def get_replies_page(status, replies):
''' actual reply list content '''
id_slug = status.remote_id + '/replies?page=true&only_other_accounts=true'
items = []
for reply in replies:
if reply.user.local:
items.append(get_status(reply))
else:
items.append(reply.remote_id)
return {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': id_slug,
'type': 'CollectionPage',
'next': '%s&min_id=%d' % (id_slug, replies[len(replies) - 1].id),
'partOf': status.remote_id + '/replies',
'items': [items]
}
def get_favorite(favorite):
''' like a post '''
return {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': favorite.remote_id,
'type': 'Like',
'actor': favorite.user.remote_id,
'object': favorite.status.remote_id,
}
def get_unfavorite(favorite):
''' like a post '''
return {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': '%s/undo' % favorite.remote_id,
'type': 'Undo',
'actor': favorite.user.remote_id,
'object': {
'id': favorite.remote_id,
'type': 'Like',
'actor': favorite.user.remote_id,
'object': favorite.status.remote_id,
}
}
def get_boost(boost):
''' boost/announce a post '''
return {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': boost.remote_id,
'type': 'Announce',
'actor': boost.user.remote_id,
'object': boost.boosted_status.remote_id,
}
def get_add_tag(tag):
''' add activity for tagging a book '''
uuid = uuid4()
return {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': str(uuid),
'type': 'Add',
'actor': tag.user.remote_id,
'object': {
'type': 'Tag',
'id': tag.remote_id,
'name': tag.name,
},
'target': {
'type': 'Book',
'id': tag.book.local_id,
}
}
def get_remove_tag(tag):
''' add activity for tagging a book '''
uuid = uuid4()
return {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': str(uuid),
'type': 'Remove',
'actor': tag.user.remote_id,
'object': {
'type': 'Tag',
'id': tag.remote_id,
'name': tag.name,
},
'target': {
'type': 'Book',
'id': tag.book.local_id,
}
}

View file

@ -0,0 +1,68 @@
''' undo wrapper activity '''
from dataclasses import dataclass
from typing import List
from .base_activity import ActivityObject, Signature
@dataclass(init=False)
class Verb(ActivityObject):
''' generic fields for activities - maybe an unecessary level of
abstraction but w/e '''
actor: str
object: ActivityObject
@dataclass(init=False)
class Create(Verb):
''' Create activity '''
to: List
cc: List
signature: Signature
type: str = 'Create'
@dataclass(init=False)
class Update(Verb):
''' Update activity '''
to: List
type: str = 'Update'
@dataclass(init=False)
class Undo(Verb):
''' Undo an activity '''
type: str = 'Undo'
@dataclass(init=False)
class Follow(Verb):
''' Follow activity '''
type: str = 'Follow'
@dataclass(init=False)
class Accept(Verb):
''' Accept activity '''
object: Follow
type: str = 'Accept'
@dataclass(init=False)
class Reject(Verb):
''' Reject activity '''
object: Follow
type: str = 'Reject'
@dataclass(init=False)
class Add(Verb):
'''Add activity '''
target: ActivityObject
type: str = 'Add'
@dataclass(init=False)
class Remove(Verb):
'''Remove activity '''
target: ActivityObject
type: str = 'Remove'

View file

@ -1,9 +1,9 @@
''' select and call a connector for whatever book task needs doing ''' ''' select and call a connector for whatever book task needs doing '''
from requests import HTTPError
import importlib import importlib
from urllib.parse import urlparse from urllib.parse import urlparse
from requests import HTTPError
from fedireads import models from fedireads import models
from fedireads.tasks import app from fedireads.tasks import app

View file

@ -4,6 +4,7 @@ from django.utils.http import http_date
import requests import requests
from fedireads import models from fedireads import models
from fedireads.activitypub import ActivityEncoder
from fedireads.tasks import app from fedireads.tasks import app
from fedireads.signatures import make_signature, make_digest from fedireads.signatures import make_signature, make_digest
@ -38,7 +39,11 @@ def broadcast(sender, activity, software=None, \
# TODO: other kinds of privacy # TODO: other kinds of privacy
if privacy == 'public': if privacy == 'public':
recipients += get_public_recipients(sender, software=software) recipients += get_public_recipients(sender, software=software)
broadcast_task.delay(sender.id, activity, recipients) broadcast_task.delay(
sender.id,
json.dumps(activity, cls=ActivityEncoder),
recipients
)
@app.task @app.task

View file

@ -1,14 +1,14 @@
''' handles all of the activity coming in to the server ''' ''' handles all of the activity coming in to the server '''
import json import json
from urllib.parse import urldefrag from urllib.parse import urldefrag
import requests
import django.db.utils import django.db.utils
from django.http import HttpResponse from django.http import HttpResponse
from django.http import HttpResponseBadRequest, HttpResponseNotFound from django.http import HttpResponseBadRequest, HttpResponseNotFound
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
import requests
from fedireads import books_manager, models, outgoing from fedireads import activitypub, books_manager, models, outgoing
from fedireads import status as status_builder from fedireads import status as status_builder
from fedireads.remote_user import get_or_create_remote_user, refresh_remote_user from fedireads.remote_user import get_or_create_remote_user, refresh_remote_user
from fedireads.tasks import app from fedireads.tasks import app
@ -84,6 +84,7 @@ def shared_inbox(request):
def has_valid_signature(request, activity): def has_valid_signature(request, activity):
''' verify incoming signature '''
try: try:
signature = Signature.parse(request) signature = Signature.parse(request)
@ -111,14 +112,13 @@ def handle_follow(activity):
''' someone wants to follow a local user ''' ''' someone wants to follow a local user '''
# figure out who they want to follow -- not using get_or_create because # figure out who they want to follow -- not using get_or_create because
# we only allow you to follow local users # we only allow you to follow local users
try:
to_follow = models.User.objects.get(remote_id=activity['object']) to_follow = models.User.objects.get(remote_id=activity['object'])
except models.User.DoesNotExist: # raises models.User.DoesNotExist id the remote id is not found
return False
# figure out who the actor is # figure out who the actor is
user = get_or_create_remote_user(activity['actor']) user = get_or_create_remote_user(activity['actor'])
try: try:
request = models.UserFollowRequest.objects.create( relationship = models.UserFollowRequest.objects.create(
user_subject=user, user_subject=user,
user_object=to_follow, user_object=to_follow,
relationship_id=activity['id'] relationship_id=activity['id']
@ -137,7 +137,7 @@ def handle_follow(activity):
'FOLLOW', 'FOLLOW',
related_user=user related_user=user
) )
outgoing.handle_accept(user, to_follow, request) outgoing.handle_accept(user, to_follow, relationship)
else: else:
status_builder.create_notification( status_builder.create_notification(
to_follow, to_follow,
@ -150,11 +150,9 @@ def handle_follow(activity):
def handle_unfollow(activity): def handle_unfollow(activity):
''' unfollow a local user ''' ''' unfollow a local user '''
obj = activity['object'] obj = activity['object']
try:
requester = get_or_create_remote_user(obj['actor']) requester = get_or_create_remote_user(obj['actor'])
to_unfollow = models.User.objects.get(remote_id=obj['object']) to_unfollow = models.User.objects.get(remote_id=obj['object'])
except models.User.DoesNotExist: # raises models.User.DoesNotExist
return False
to_unfollow.followers.remove(requester) to_unfollow.followers.remove(requester)
@ -184,67 +182,63 @@ def handle_follow_reject(activity):
requester = models.User.objects.get(remote_id=activity['object']['actor']) requester = models.User.objects.get(remote_id=activity['object']['actor'])
rejecter = get_or_create_remote_user(activity['actor']) rejecter = get_or_create_remote_user(activity['actor'])
try:
request = models.UserFollowRequest.objects.get( request = models.UserFollowRequest.objects.get(
user_subject=requester, user_subject=requester,
user_object=rejecter user_object=rejecter
) )
request.delete() request.delete()
except models.UserFollowRequest.DoesNotExist: #raises models.UserFollowRequest.DoesNotExist:
return False
@app.task @app.task
def handle_create(activity): def handle_create(activity):
''' someone did something, good on them ''' ''' someone did something, good on them '''
user = get_or_create_remote_user(activity['actor']) if activity['object'].get('type') not in \
['Note', 'Comment', 'Quotation', 'Review']:
# if it's an article or unknown type, ignore it
return
user = get_or_create_remote_user(activity['actor'])
if user.local: if user.local:
# we really oughtn't even be sending in this case # we really oughtn't even be sending in this case
return True return
if activity['object'].get('fedireadsType') and \ # render the json into an activity object
'inReplyToBook' in activity['object']: serializer = activitypub.activity_objects[activity['object']['type']]
if activity['object']['fedireadsType'] == 'Review': activity = serializer(**activity['object'])
builder = status_builder.create_review_from_activity
elif activity['object']['fedireadsType'] == 'Quotation':
builder = status_builder.create_quotation_from_activity
else:
builder = status_builder.create_comment_from_activity
# create the status, it'll throw a ValueError if anything is missing # ignore notes that aren't replies to known statuses
builder(user, activity['object']) if activity.type == 'Note':
elif activity['object'].get('inReplyTo'): reply = models.Status.objects.filter(
# only create the status if it's in reply to a status we already know remote_id=activity.inReplyTo
if not status_builder.get_status(activity['object']['inReplyTo']): ).first()
return True if not reply:
return
status = status_builder.create_status_from_activity( model = models.activity_models[activity.type]
user, status = activity.to_model(model)
activity['object']
) # create a notification if this is a reply
if status and status.reply_parent: if status.reply_parent and status.reply_parent.user.local:
status_builder.create_notification( status_builder.create_notification(
status.reply_parent.user, status.reply_parent.user,
'REPLY', 'REPLY',
related_user=status.user, related_user=status.user,
related_status=status, related_status=status,
) )
return True
@app.task @app.task
def handle_favorite(activity): def handle_favorite(activity):
''' approval of your good good post ''' ''' approval of your good good post '''
try: fav = activitypub.Like(**activity['object'])
status_id = activity['object'].split('/')[-1] # raises ValueError in to_model if a foreign key could not be resolved in
status = models.Status.objects.get(id=status_id)
liker = get_or_create_remote_user(activity['actor'])
except (models.Status.DoesNotExist, models.User.DoesNotExist):
return False
if not liker.local: liker = get_or_create_remote_user(activity['actor'])
status_builder.create_favorite_from_activity(liker, activity) if liker.local:
return
status = fav.to_model(models.Favorite)
status_builder.create_notification( status_builder.create_notification(
status.user, status.user,
@ -257,10 +251,8 @@ def handle_favorite(activity):
@app.task @app.task
def handle_unfavorite(activity): def handle_unfavorite(activity):
''' approval of your good good post ''' ''' approval of your good good post '''
favorite_id = activity['object']['id'] like = activitypub.Like(**activity['object'])
fav = models.Favorite.objects.filter(remote_id=favorite_id).first() fav = models.Favorite.objects.filter(remote_id=like.id).first()
if not fav:
return False
fav.delete() fav.delete()
@ -268,12 +260,9 @@ def handle_unfavorite(activity):
@app.task @app.task
def handle_boost(activity): def handle_boost(activity):
''' someone gave us a boost! ''' ''' someone gave us a boost! '''
try:
status_id = activity['object'].split('/')[-1] status_id = activity['object'].split('/')[-1]
status = models.Status.objects.get(id=status_id) status = models.Status.objects.get(id=status_id)
booster = get_or_create_remote_user(activity['actor']) booster = get_or_create_remote_user(activity['actor'])
except (models.Status.DoesNotExist, models.User.DoesNotExist):
return False
if not booster.local: if not booster.local:
status_builder.create_boost_from_activity(booster, activity) status_builder.create_boost_from_activity(booster, activity)

View file

@ -0,0 +1,21 @@
# Generated by Django 3.0.3 on 2020-05-24 03:46
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('fedireads', '0041_user_remote_id'),
]
operations = [
migrations.RemoveField(
model_name='status',
name='activity_type',
),
migrations.RemoveField(
model_name='status',
name='status_type',
),
]

View file

@ -0,0 +1,14 @@
# Generated by Django 3.0.7 on 2020-08-10 20:10
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('fedireads', '0044_siteinvite_user'),
('fedireads', '0042_auto_20200524_0346'),
]
operations = [
]

View file

@ -1,9 +1,17 @@
''' bring all the models into the app namespace ''' ''' bring all the models into the app namespace '''
import inspect
import sys
from .book import Connector, Book, Work, Edition, Author from .book import Connector, Book, Work, Edition, Author
from .shelf import Shelf, ShelfBook from .shelf import Shelf, ShelfBook
from .status import Status, Review, Comment, Quotation from .status import Status, Review, Comment, Quotation
from .status import Favorite, Boost, Tag, Notification, ReadThrough from .status import Favorite, Boost, Tag, Notification, ReadThrough
from .user import User, UserFollows, UserFollowRequest, UserBlocks from .user import User, UserFollows, UserFollowRequest, UserBlocks
from .user import FederatedServer from .user import FederatedServer
from .import_job import ImportJob, ImportItem from .import_job import ImportJob, ImportItem
from .site import SiteSettings, SiteInvite from .site import SiteSettings, SiteInvite
cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass)
activity_models = {c[0]: c[1].activity_serializer for c in cls_members \
if hasattr(c[1], 'activity_serializer')}

View file

@ -1,11 +1,21 @@
''' base model with default fields ''' ''' base model with default fields '''
from base64 import b64encode
from dataclasses import dataclass
from typing import Callable
from uuid import uuid4
from urllib.parse import urlencode
from Crypto.PublicKey import RSA
from Crypto.Signature import pkcs1_15
from Crypto.Hash import SHA256
from django.db import models from django.db import models
from django.dispatch import receiver from django.dispatch import receiver
from fedireads import activitypub
from fedireads.settings import DOMAIN from fedireads.settings import DOMAIN
class FedireadsModel(models.Model): class FedireadsModel(models.Model):
''' fields and functions for every model ''' ''' shared fields '''
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)
remote_id = models.CharField(max_length=255, null=True) remote_id = models.CharField(max_length=255, null=True)
@ -19,6 +29,7 @@ class FedireadsModel(models.Model):
return '%s/%s/%d' % (base_path, model_name, self.id) return '%s/%s/%d' % (base_path, model_name, self.id)
class Meta: class Meta:
''' this is just here to provide default fields for other models '''
abstract = True abstract = True
@ -30,3 +41,179 @@ def execute_after_save(sender, instance, created, *args, **kwargs):
if not instance.remote_id: if not instance.remote_id:
instance.remote_id = instance.get_remote_id() instance.remote_id = instance.get_remote_id()
instance.save() instance.save()
class ActivitypubMixin:
''' add this mixin for models that are AP serializable '''
activity_serializer = lambda: {}
def to_activity(self, pure=False):
''' convert from a model to an activity '''
if pure:
mappings = self.pure_activity_mappings
else:
mappings = self.activity_mappings
fields = {}
for mapping in mappings:
if not hasattr(self, mapping.model_key) or not mapping.activity_key:
continue
value = getattr(self, mapping.model_key)
if hasattr(value, 'remote_id'):
value = value.remote_id
fields[mapping.activity_key] = mapping.activity_formatter(value)
if pure:
return self.pure_activity_serializer(
**fields
).serialize()
return self.activity_serializer(
**fields
).serialize()
def to_create_activity(self, user, pure=False):
''' returns the object wrapped in a Create activity '''
activity_object = self.to_activity(pure=pure)
signer = pkcs1_15.new(RSA.import_key(user.private_key))
content = activity_object['content']
signed_message = signer.sign(SHA256.new(content.encode('utf8')))
create_id = self.remote_id + '/activity'
signature = activitypub.Signature(
creator='%s#main-key' % user.remote_id,
created=activity_object['published'],
signatureValue=b64encode(signed_message).decode('utf8')
)
return activitypub.Create(
id=create_id,
actor=user.remote_id,
to=['%s/followers' % user.remote_id],
cc=['https://www.w3.org/ns/activitystreams#Public'],
object=activity_object,
signature=signature,
).serialize()
def to_update_activity(self, user):
''' wrapper for Updates to an activity '''
activity_id = '%s#update/%s' % (user.remote_id, uuid4())
return activitypub.Update(
id=activity_id,
actor=user.remote_id,
to=['https://www.w3.org/ns/activitystreams#Public'],
object=self.to_activity()
).serialize()
def to_undo_activity(self, user):
''' undo an action '''
return activitypub.Undo(
id='%s#undo' % user.remote_id,
actor=user.remote_id,
object=self.to_activity()
)
class OrderedCollectionPageMixin(ActivitypubMixin):
''' just the paginator utilities, so you don't HAVE to
override ActivitypubMixin's to_activity (ie, for outbox '''
@property
def collection_remote_id(self):
''' this can be overriden if there's a special remote id, ie outbox '''
return self.remote_id
def page(self, min_id=None, max_id=None):
''' helper function to create the pagination url '''
params = {'page': 'true'}
if min_id:
params['min_id'] = min_id
if max_id:
params['max_id'] = max_id
return '?%s' % urlencode(params)
def next_page(self, items):
''' use the max id of the last item '''
if not items.count():
return ''
return self.page(max_id=items[items.count() - 1].id)
def prev_page(self, items):
''' use the min id of the first item '''
if not items.count():
return ''
return self.page(min_id=items[0].id)
def to_ordered_collection_page(self, queryset, remote_id, \
id_only=False, min_id=None, max_id=None):
''' serialize and pagiante a queryset '''
# TODO: weird place to define this
limit = 20
# filters for use in the django queryset min/max
filters = {}
if min_id is not None:
filters['id__gt'] = min_id
if max_id is not None:
filters['id__lte'] = max_id
page_id = self.page(min_id=min_id, max_id=max_id)
items = queryset.filter(
**filters
).all()[:limit]
if id_only:
page = [s.remote_id for s in items]
else:
page = [s.to_activity() for s in items]
return activitypub.OrderedCollectionPage(
id='%s%s' % (remote_id, page_id),
partOf=remote_id,
orderedItems=page,
next='%s%s' % (remote_id, self.next_page(items)),
prev='%s%s' % (remote_id, self.prev_page(items))
).serialize()
def to_ordered_collection(self, queryset, \
remote_id=None, page=False, **kwargs):
''' an ordered collection of whatevers '''
remote_id = remote_id or self.remote_id
if page:
return self.to_ordered_collection_page(
queryset, remote_id, **kwargs)
name = ''
if hasattr(self, 'name'):
name = self.name
size = queryset.count()
return activitypub.OrderedCollection(
id=remote_id,
totalItems=size,
name=name,
first='%s%s' % (remote_id, self.page()),
last='%s%s' % (remote_id, self.page(min_id=0))
).serialize()
class OrderedCollectionMixin(OrderedCollectionPageMixin):
''' extends activitypub models to work as ordered collections '''
@property
def collection_queryset(self):
''' usually an ordered collection model aggregates a different model '''
raise NotImplementedError('Model must define collection_queryset')
activity_serializer = activitypub.OrderedCollection
def to_activity(self, **kwargs):
''' an ordered collection of the specified model queryset '''
return self.to_ordered_collection(self.collection_queryset, **kwargs)
@dataclass(frozen=True)
class ActivityMapping:
''' translate between an activitypub json field and a model field '''
activity_key: str
model_key: str
activity_formatter: Callable = lambda x: x
model_formatter: Callable = lambda x: x

View file

@ -1,15 +1,16 @@
''' database schema for books and shelves ''' ''' database schema for books and shelves '''
from django.utils import timezone
from django.db import models from django.db import models
from django.utils import timezone
from django.utils.http import http_date
from model_utils.managers import InheritanceManager from model_utils.managers import InheritanceManager
from fedireads import activitypub from fedireads import activitypub
from fedireads.settings import DOMAIN from fedireads.settings import DOMAIN
from fedireads.utils.fields import ArrayField from fedireads.utils.fields import ArrayField
from .base_model import FedireadsModel
from fedireads.connectors.settings import CONNECTORS from fedireads.connectors.settings import CONNECTORS
from .base_model import ActivityMapping, ActivitypubMixin, FedireadsModel
ConnectorFiles = models.TextChoices('ConnectorFiles', CONNECTORS) ConnectorFiles = models.TextChoices('ConnectorFiles', CONNECTORS)
class Connector(FedireadsModel): class Connector(FedireadsModel):
@ -45,7 +46,7 @@ class Connector(FedireadsModel):
] ]
class Book(FedireadsModel): class Book(ActivitypubMixin, FedireadsModel):
''' a generic book, which can mean either an edition or a work ''' ''' a generic book, which can mean either an edition or a work '''
# these identifiers apply to both works and editions # these identifiers apply to both works and editions
openlibrary_key = models.CharField(max_length=255, blank=True, null=True) openlibrary_key = models.CharField(max_length=255, blank=True, null=True)
@ -86,6 +87,52 @@ class Book(FedireadsModel):
published_date = models.DateTimeField(blank=True, null=True) published_date = models.DateTimeField(blank=True, null=True)
objects = InheritanceManager() objects = InheritanceManager()
@property
def ap_authors(self):
return [a.remote_id for a in self.authors.all()]
activity_mappings = [
ActivityMapping('id', 'remote_id'),
ActivityMapping('authors', 'ap_authors'),
ActivityMapping(
'first_published_date',
'first_published_date',
activity_formatter=lambda d: http_date(d.timestamp()) if d else None
),
ActivityMapping(
'published_date',
'published_date',
activity_formatter=lambda d: http_date(d.timestamp()) if d else None
),
ActivityMapping('title', 'title'),
ActivityMapping('sort_title', 'sort_title'),
ActivityMapping('subtitle', 'subtitle'),
ActivityMapping('description', 'description'),
ActivityMapping('languages', 'languages'),
ActivityMapping('series', 'series'),
ActivityMapping('series_number', 'series_number'),
ActivityMapping('subjects', 'subjects'),
ActivityMapping('subject_places', 'subject_places'),
ActivityMapping('openlibrary_key', 'openlibrary_key'),
ActivityMapping('librarything_key', 'librarything_key'),
ActivityMapping('goodreads_key', 'goodreads_key'),
ActivityMapping('work', 'parent_work'),
ActivityMapping('isbn_10', 'isbn_10'),
ActivityMapping('isbn_13', 'isbn_13'),
ActivityMapping('oclc_number', 'oclc_number'),
ActivityMapping('asin', 'asin'),
ActivityMapping('pages', 'pages'),
ActivityMapping('physical_format', 'physical_format'),
ActivityMapping('publishers', 'publishers'),
ActivityMapping('lccn', 'lccn'),
ActivityMapping('editions', 'editions_path'),
]
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
''' can't be abstract for query reasons, but you shouldn't USE it ''' ''' can't be abstract for query reasons, but you shouldn't USE it '''
if not isinstance(self, Edition) and not isinstance(self, Work): if not isinstance(self, Edition) and not isinstance(self, Work):
@ -106,7 +153,6 @@ class Book(FedireadsModel):
the remote canonical copy ''' the remote canonical copy '''
return 'https://%s/book/%d' % (DOMAIN, self.id) return 'https://%s/book/%d' % (DOMAIN, self.id)
def __repr__(self): def __repr__(self):
return "<{} key={!r} title={!r}>".format( return "<{} key={!r} title={!r}>".format(
self.__class__, self.__class__,
@ -114,16 +160,17 @@ class Book(FedireadsModel):
self.title, self.title,
) )
@property
def activitypub_serialize(self):
return activitypub.get_book(self)
class Work(Book): class Work(Book):
''' a work (an abstract concept of a book that manifests in an edition) ''' ''' a work (an abstract concept of a book that manifests in an edition) '''
# library of congress catalog control number # library of congress catalog control number
lccn = models.CharField(max_length=255, blank=True, null=True) lccn = models.CharField(max_length=255, blank=True, null=True)
@property
def editions_path(self):
return self.remote_id + '/editions'
@property @property
def default_edition(self): def default_edition(self):
ed = Edition.objects.filter(parent_work=self, default=True).first() ed = Edition.objects.filter(parent_work=self, default=True).first()
@ -131,6 +178,8 @@ class Work(Book):
ed = Edition.objects.filter(parent_work=self).first() ed = Edition.objects.filter(parent_work=self).first()
return ed return ed
activity_serializer = activitypub.Work
class Edition(Book): class Edition(Book):
''' an edition of a book ''' ''' an edition of a book '''
@ -155,8 +204,10 @@ class Edition(Book):
) )
parent_work = models.ForeignKey('Work', on_delete=models.PROTECT, null=True) parent_work = models.ForeignKey('Work', on_delete=models.PROTECT, null=True)
activity_serializer = activitypub.Edition
class Author(FedireadsModel):
class Author(ActivitypubMixin, FedireadsModel):
''' copy of an author from OL ''' ''' copy of an author from OL '''
openlibrary_key = models.CharField(max_length=255, blank=True, null=True) openlibrary_key = models.CharField(max_length=255, blank=True, null=True)
sync = models.BooleanField(default=True) sync = models.BooleanField(default=True)
@ -181,17 +232,25 @@ class Author(FedireadsModel):
the remote canonical copy (ditto here for author)''' the remote canonical copy (ditto here for author)'''
return 'https://%s/book/%d' % (DOMAIN, self.id) return 'https://%s/book/%d' % (DOMAIN, self.id)
@property
def activitypub_serialize(self):
return activitypub.get_author(self)
@property @property
def display_name(self): def display_name(self):
''' Helper to return a displayable name''' ''' Helper to return a displayable name'''
if self.name: if self.name:
return name return self.name
# don't want to return a spurious space if all of these are None # don't want to return a spurious space if all of these are None
elif self.first_name and self.last_name: if self.first_name and self.last_name:
return self.first_name + ' ' + self.last_name return self.first_name + ' ' + self.last_name
else:
return self.last_name or self.first_name return self.last_name or self.first_name
activity_mappings = [
ActivityMapping('id', 'remote_id'),
ActivityMapping('url', 'remote_id'),
ActivityMapping('name', 'display_name'),
ActivityMapping('born', 'born'),
ActivityMapping('died', 'died'),
ActivityMapping('aliases', 'aliases'),
ActivityMapping('bio', 'bio'),
ActivityMapping('openlibrary_key', 'openlibrary_key'),
ActivityMapping('wikipedia_link', 'wikipedia_link'),
]
activity_serializer = activitypub.Author

View file

@ -1,10 +1,12 @@
''' puttin' books on shelves ''' ''' puttin' books on shelves '''
from django.db import models from django.db import models
from .base_model import FedireadsModel from fedireads import activitypub
from .base_model import FedireadsModel, OrderedCollectionMixin
class Shelf(FedireadsModel): class Shelf(OrderedCollectionMixin, FedireadsModel):
''' a list of books owned by a user '''
name = models.CharField(max_length=100) name = models.CharField(max_length=100)
identifier = models.CharField(max_length=100) identifier = models.CharField(max_length=100)
user = models.ForeignKey('User', on_delete=models.PROTECT) user = models.ForeignKey('User', on_delete=models.PROTECT)
@ -16,17 +18,23 @@ class Shelf(FedireadsModel):
through_fields=('shelf', 'book') through_fields=('shelf', 'book')
) )
@property
def collection_queryset(self):
''' list of books for this shelf, overrides OrderedCollectionMixin '''
return self.books
def get_remote_id(self): def get_remote_id(self):
''' shelf identifier instead of id ''' ''' shelf identifier instead of id '''
base_path = self.user.remote_id base_path = self.user.remote_id
return '%s/shelf/%s' % (base_path, self.identifier) return '%s/shelf/%s' % (base_path, self.identifier)
class Meta: class Meta:
''' user/shelf unqiueness '''
unique_together = ('user', 'identifier') unique_together = ('user', 'identifier')
class ShelfBook(FedireadsModel): class ShelfBook(FedireadsModel):
# many to many join table for books and shelves ''' many to many join table for books and shelves '''
book = models.ForeignKey('Edition', on_delete=models.PROTECT) book = models.ForeignKey('Edition', on_delete=models.PROTECT)
shelf = models.ForeignKey('Shelf', on_delete=models.PROTECT) shelf = models.ForeignKey('Shelf', on_delete=models.PROTECT)
added_by = models.ForeignKey( added_by = models.ForeignKey(
@ -36,5 +44,26 @@ class ShelfBook(FedireadsModel):
on_delete=models.PROTECT on_delete=models.PROTECT
) )
def to_add_activity(self, user):
''' AP for shelving a book'''
return activitypub.Add(
id='%s#add' % self.remote_id,
actor=user.remote_id,
object=self.book.to_activity(),
target=self.shelf.to_activity()
).serialize()
def to_remove_activity(self, user):
''' AP for un-shelving a book'''
return activitypub.Remove(
id='%s#remove' % self.remote_id,
actor=user.remote_id,
object=self.book.to_activity(),
target=self.shelf.to_activity()
).serialize()
class Meta: class Meta:
''' an opinionated constraint!
you can't put a book on shelf twice '''
unique_together = ('book', 'shelf') unique_together = ('book', 'shelf')

View file

@ -3,6 +3,7 @@ import base64
from Crypto import Random from Crypto import Random
from django.db import models from django.db import models
from django.utils import timezone from django.utils import timezone
import datetime
from fedireads.settings import DOMAIN from fedireads.settings import DOMAIN
from .user import User from .user import User

View file

@ -2,23 +2,25 @@
import urllib.parse import urllib.parse
from django.utils import timezone from django.utils import timezone
from django.utils.http import http_date
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from model_utils.managers import InheritanceManager from model_utils.managers import InheritanceManager
from fedireads import activitypub from fedireads import activitypub
from .base_model import FedireadsModel from fedireads.settings import DOMAIN
from .base_model import ActivitypubMixin, OrderedCollectionMixin, \
OrderedCollectionPageMixin
from .base_model import ActivityMapping, FedireadsModel
class Status(FedireadsModel): class Status(OrderedCollectionPageMixin, FedireadsModel):
''' any post, like a reply to a review, etc ''' ''' any post, like a reply to a review, etc '''
user = models.ForeignKey('User', on_delete=models.PROTECT) user = models.ForeignKey('User', on_delete=models.PROTECT)
status_type = models.CharField(max_length=255, default='Note')
content = models.TextField(blank=True, null=True) content = models.TextField(blank=True, null=True)
mention_users = models.ManyToManyField('User', related_name='mention_user') mention_users = models.ManyToManyField('User', related_name='mention_user')
mention_books = models.ManyToManyField( mention_books = models.ManyToManyField(
'Edition', related_name='mention_book') 'Edition', related_name='mention_book')
activity_type = models.CharField(max_length=255, default='Note')
local = models.BooleanField(default=True) local = models.BooleanField(default=True)
privacy = models.CharField(max_length=255, default='public') privacy = models.CharField(max_length=255, default='public')
sensitive = models.BooleanField(default=False) sensitive = models.BooleanField(default=False)
@ -38,40 +40,100 @@ class Status(FedireadsModel):
) )
objects = InheritanceManager() objects = InheritanceManager()
# ---- activitypub serialization settings for this model ----- #
@property @property
def activitypub_serialize(self): def ap_to(self):
return activitypub.get_status(self) ''' should be related to post privacy I think '''
return ['https://www.w3.org/ns/activitystreams#Public']
@property
def ap_cc(self):
''' should be related to post privacy I think '''
return [self.user.ap_followers]
@property
def ap_replies(self):
''' structured replies block '''
return self.to_replies()
shared_mappings = [
ActivityMapping('id', 'remote_id'),
ActivityMapping('url', 'remote_id'),
ActivityMapping('inReplyTo', 'reply_parent'),
ActivityMapping(
'published',
'published_date',
activity_formatter=lambda d: http_date(d.timestamp())
),
ActivityMapping('attributedTo', 'user'),
ActivityMapping('to', 'ap_to'),
ActivityMapping('cc', 'ap_cc'),
ActivityMapping('replies', 'ap_replies'),
]
# serializing to fedireads expanded activitypub
activity_mappings = shared_mappings + [
ActivityMapping('name', 'name'),
ActivityMapping('inReplyToBook', 'book'),
ActivityMapping('rating', 'rating'),
ActivityMapping('quote', 'quote'),
ActivityMapping('content', 'content'),
]
# for serializing to standard activitypub without extended types
pure_activity_mappings = shared_mappings + [
ActivityMapping('name', 'pure_ap_name'),
ActivityMapping('content', 'ap_pure_content'),
]
activity_serializer = activitypub.Note
#----- replies collection activitypub ----#
@classmethod
def replies(cls, status):
''' load all replies to a status. idk if there's a better way
to write this so it's just a property '''
return cls.objects.filter(reply_parent=status).select_subclasses()
def to_replies(self, **kwargs):
''' helper function for loading AP serialized replies to a status '''
return self.to_ordered_collection(
self.replies(self),
remote_id='%s/replies' % self.remote_id,
**kwargs
)
class Comment(Status): class Comment(Status):
''' like a review but without a rating and transient ''' ''' like a review but without a rating and transient '''
book = models.ForeignKey('Edition', on_delete=models.PROTECT) book = models.ForeignKey('Edition', on_delete=models.PROTECT)
def save(self, *args, **kwargs):
self.status_type = 'Comment'
self.activity_type = 'Note'
super().save(*args, **kwargs)
@property @property
def activitypub_serialize(self): def ap_pure_content(self):
return activitypub.get_comment(self) ''' indicate the book in question for mastodon (or w/e) users '''
return self.content + '<br><br>(comment on <a href="%s">"%s"</a>)' % \
(self.book.local_id, self.book.title)
activity_serializer = activitypub.Comment
pure_activity_serializer = activitypub.Note
class Quotation(Status): class Quotation(Status):
''' like a review but without a rating and transient ''' ''' like a review but without a rating and transient '''
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
quote = models.TextField() quote = models.TextField()
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
def save(self, *args, **kwargs):
self.status_type = 'Quotation'
self.activity_type = 'Note'
super().save(*args, **kwargs)
@property @property
def activitypub_serialize(self): def ap_pure_content(self):
return activitypub.get_quotation(self) ''' indicate the book in question for mastodon (or w/e) users '''
return '"%s"<br>-- <a href="%s">"%s"</a>)<br><br>%s' % (
self.quote,
self.book.local_id,
self.book.title,
self.content,
)
activity_serializer = activitypub.Quotation
class Review(Status): class Review(Status):
@ -85,23 +147,41 @@ class Review(Status):
validators=[MinValueValidator(1), MaxValueValidator(5)] validators=[MinValueValidator(1), MaxValueValidator(5)]
) )
def save(self, *args, **kwargs): @property
self.status_type = 'Review' def ap_pure_name(self):
self.activity_type = 'Article' ''' clarify review names for mastodon serialization '''
super().save(*args, **kwargs) return 'Review of "%s" (%d stars): %s' % (
self.book.title,
self.rating,
self.name
)
@property @property
def activitypub_serialize(self): def ap_pure_content(self):
return activitypub.get_review(self) ''' indicate the book in question for mastodon (or w/e) users '''
return self.content + '<br><br>(<a href="%s">"%s"</a>)' % \
(self.book.local_id, self.book.title)
activity_serializer = activitypub.Review
class Favorite(FedireadsModel): class Favorite(ActivitypubMixin, FedireadsModel):
''' fav'ing a post ''' ''' fav'ing a post '''
user = models.ForeignKey('User', on_delete=models.PROTECT) user = models.ForeignKey('User', on_delete=models.PROTECT)
status = models.ForeignKey('Status', on_delete=models.PROTECT) status = models.ForeignKey('Status', on_delete=models.PROTECT)
# ---- activitypub serialization settings for this model ----- #
activity_mappings = [
ActivityMapping('id', 'remote_id'),
ActivityMapping('actor', 'user'),
ActivityMapping('object', 'status'),
]
activity_serializer = activitypub.Like
class Meta: class Meta:
''' can't fav things twice '''
unique_together = ('user', 'status') unique_together = ('user', 'status')
@ -112,29 +192,69 @@ class Boost(Status):
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name="boosters") related_name="boosters")
def save(self, *args, **kwargs): activity_mappings = [
self.status_type = 'Boost' ActivityMapping('id', 'remote_id'),
self.activity_type = 'Announce' ActivityMapping('actor', 'user'),
super().save(*args, **kwargs) ActivityMapping('object', 'boosted_status'),
]
activity_serializer = activitypub.Like
# This constraint can't work as it would cross tables. # This constraint can't work as it would cross tables.
# class Meta: # class Meta:
# unique_together = ('user', 'boosted_status') # unique_together = ('user', 'boosted_status')
class Tag(FedireadsModel):
class Tag(OrderedCollectionMixin, FedireadsModel):
''' freeform tags for books ''' ''' freeform tags for books '''
user = models.ForeignKey('User', on_delete=models.PROTECT) user = models.ForeignKey('User', on_delete=models.PROTECT)
book = models.ForeignKey('Edition', on_delete=models.PROTECT) book = models.ForeignKey('Edition', on_delete=models.PROTECT)
name = models.CharField(max_length=100) name = models.CharField(max_length=100)
identifier = models.CharField(max_length=100) identifier = models.CharField(max_length=100)
@classmethod
def book_queryset(cls, identifier):
''' county of books associated with this tag '''
return cls.objects.filter(identifier=identifier)
@property
def collection_queryset(self):
''' books associated with this tag '''
return self.book_queryset(self.identifier)
def get_remote_id(self):
''' tag should use identifier not id in remote_id '''
base_path = 'https://%s' % DOMAIN
return '%s/tag/%s' % (base_path, self.identifier)
def to_add_activity(self, user):
''' AP for shelving a book'''
return activitypub.Add(
id='%s#add' % self.remote_id,
actor=user.remote_id,
object=self.book.to_activity(),
target=self.to_activity(),
).serialize()
def to_remove_activity(self, user):
''' AP for un-shelving a book'''
return activitypub.Remove(
id='%s#remove' % self.remote_id,
actor=user.remote_id,
object=self.book.to_activity(),
target=self.to_activity(),
).serialize()
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
''' create a url-safe lookup key for the tag '''
if not self.id: if not self.id:
# add identifiers to new tags # add identifiers to new tags
self.identifier = urllib.parse.quote_plus(self.name) self.identifier = urllib.parse.quote_plus(self.name)
super().save(*args, **kwargs) super().save(*args, **kwargs)
class Meta: class Meta:
''' unqiueness constraint '''
unique_together = ('user', 'book', 'name') unique_together = ('user', 'book', 'name')
@ -172,7 +292,9 @@ class Notification(FedireadsModel):
read = models.BooleanField(default=False) read = models.BooleanField(default=False)
notification_type = models.CharField( notification_type = models.CharField(
max_length=255, choices=NotificationType.choices) max_length=255, choices=NotificationType.choices)
class Meta: class Meta:
''' checks if notifcation is in enum list for valid types '''
constraints = [ constraints = [
models.CheckConstraint( models.CheckConstraint(
check=models.Q(notification_type__in=NotificationType.values), check=models.Q(notification_type__in=NotificationType.values),

View file

@ -1,16 +1,20 @@
''' database schema for user data ''' ''' database schema for user data '''
from urllib.parse import urlparse
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.db import models from django.db import models
from django.dispatch import receiver from django.dispatch import receiver
from fedireads import activitypub from fedireads import activitypub
from fedireads.models.shelf import Shelf from fedireads.models.shelf import Shelf
from fedireads.models.status import Status
from fedireads.settings import DOMAIN from fedireads.settings import DOMAIN
from fedireads.signatures import create_key_pair from fedireads.signatures import create_key_pair
from .base_model import FedireadsModel from .base_model import OrderedCollectionPageMixin
from .base_model import ActivityMapping, FedireadsModel
class User(AbstractUser): class User(OrderedCollectionPageMixin, AbstractUser):
''' a user who wants to read books ''' ''' a user who wants to read books '''
private_key = models.TextField(blank=True, null=True) private_key = models.TextField(blank=True, null=True)
public_key = models.TextField(blank=True, null=True) public_key = models.TextField(blank=True, null=True)
@ -66,9 +70,102 @@ class User(AbstractUser):
updated_date = models.DateTimeField(auto_now=True) updated_date = models.DateTimeField(auto_now=True)
manually_approves_followers = models.BooleanField(default=False) manually_approves_followers = models.BooleanField(default=False)
# ---- activitypub serialization settings for this model ----- #
@property @property
def activitypub_serialize(self): def ap_followers(self):
return activitypub.get_actor(self) ''' generates url for activitypub followers page '''
return '%s/followers' % self.remote_id
@property
def ap_icon(self):
''' send default icon if one isn't set '''
if self.avatar:
url = self.avatar.url
# TODO not the right way to get the media type
media_type = 'image/%s' % url.split('.')[-1]
else:
url = '%s/static/images/default_avi.jpg' % DOMAIN
media_type = 'image/jpeg'
return activitypub.Image(media_type, url, 'Image')
@property
def ap_public_key(self):
''' format the public key block for activitypub '''
return activitypub.PublicKey(**{
'id': '%s/#main-key' % self.remote_id,
'owner': self.remote_id,
'publicKeyPem': self.public_key,
})
activity_mappings = [
ActivityMapping('id', 'remote_id'),
ActivityMapping(
'preferredUsername',
'username',
activity_formatter=lambda x: x.split('@')[0]
),
ActivityMapping('name', 'name'),
ActivityMapping('inbox', 'inbox'),
ActivityMapping('outbox', 'outbox'),
ActivityMapping('followers', 'ap_followers'),
ActivityMapping('summary', 'summary'),
ActivityMapping(
'publicKey',
'public_key',
model_formatter=lambda x: x.get('publicKeyPem')
),
ActivityMapping('publicKey', 'ap_public_key'),
ActivityMapping(
'endpoints',
'shared_inbox',
activity_formatter=lambda x: {'sharedInbox': x},
model_formatter=lambda x: x.get('sharedInbox')
),
ActivityMapping('icon', 'ap_icon'),
ActivityMapping(
'manuallyApprovesFollowers',
'manually_approves_followers'
),
# this field isn't in the activity but should always be false
ActivityMapping(None, 'local', model_formatter=lambda x: False),
]
activity_serializer = activitypub.Person
def to_outbox(self, **kwargs):
''' an ordered collection of statuses '''
queryset = Status.objects.filter(
user=self,
).select_subclasses()
return self.to_ordered_collection(queryset, \
remote_id=self.outbox, **kwargs)
def to_following_activity(self, **kwargs):
''' activitypub following list '''
remote_id = '%s/following' % self.remote_id
return self.to_ordered_collection(self.following, \
remote_id=remote_id, id_only=True, **kwargs)
def to_followers_activity(self, **kwargs):
''' activitypub followers list '''
remote_id = '%s/followers' % self.remote_id
return self.to_ordered_collection(self.followers, \
remote_id=remote_id, id_only=True, **kwargs)
def to_activity(self, pure=False):
''' override default AP serializer to add context object
idk if this is the best way to go about this '''
activity_object = super().to_activity()
activity_object['@context'] = [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
{
'manuallyApprovesFollowers': 'as:manuallyApprovesFollowers',
'schema': 'http://schema.org#',
'PropertyValue': 'schema:PropertyValue',
'value': 'schema:value',
}
]
return activity_object
class UserRelationship(FedireadsModel): class UserRelationship(FedireadsModel):
@ -87,6 +184,7 @@ class UserRelationship(FedireadsModel):
relationship_id = models.CharField(max_length=100) relationship_id = models.CharField(max_length=100)
class Meta: class Meta:
''' relationships should be unique '''
abstract = True abstract = True
constraints = [ constraints = [
models.UniqueConstraint( models.UniqueConstraint(
@ -106,12 +204,14 @@ class UserRelationship(FedireadsModel):
class UserFollows(UserRelationship): class UserFollows(UserRelationship):
''' Following a user '''
@property @property
def status(self): def status(self):
return 'follows' return 'follows'
@classmethod @classmethod
def from_request(cls, follow_request): def from_request(cls, follow_request):
''' converts a follow request into a follow relationship '''
return cls( return cls(
user_subject=follow_request.user_subject, user_subject=follow_request.user_subject,
user_object=follow_request.user_object, user_object=follow_request.user_object,
@ -120,10 +220,35 @@ class UserFollows(UserRelationship):
class UserFollowRequest(UserRelationship): class UserFollowRequest(UserRelationship):
''' following a user requires manual or automatic confirmation '''
@property @property
def status(self): def status(self):
return 'follow_request' return 'follow_request'
def to_activity(self):
''' request activity '''
return activitypub.Follow(
id=self.remote_id,
actor=self.user_subject.remote_id,
object=self.user_object.remote_id,
).serialize()
def to_accept_activity(self):
''' generate an Accept for this follow request '''
return activitypub.Accept(
id='%s#accepts/follows/' % self.remote_id,
actor=self.user_subject.remote_id,
object=self.user_object.remote_id,
).serialize()
def to_reject_activity(self):
''' generate an Accept for this follow request '''
return activitypub.Reject(
id='%s#rejects/follows/' % self.remote_id,
actor=self.user_subject.remote_id,
object=self.user_object.remote_id,
).serialize()
class UserBlocks(UserRelationship): class UserBlocks(UserRelationship):
@property @property
@ -145,7 +270,12 @@ class FederatedServer(FedireadsModel):
def execute_before_save(sender, instance, *args, **kwargs): def execute_before_save(sender, instance, *args, **kwargs):
''' populate fields for new local users ''' ''' populate fields for new local users '''
# this user already exists, no need to poplate fields # this user already exists, no need to poplate fields
if instance.id or not instance.local: if instance.id:
return
if not instance.local:
# we need to generate a username that uses the domain (webfinger format)
actor_parts = urlparse(instance.remote_id)
instance.username = '%s@%s' % (instance.username, actor_parts.netloc)
return return
# populate fields for local users # populate fields for local users

View file

@ -1,6 +1,5 @@
''' handles all the activity coming out of the server ''' ''' handles all the activity coming out of the server '''
from datetime import datetime from datetime import datetime
from urllib.parse import urlencode
from django.db import IntegrityError, transaction from django.db import IntegrityError, transaction
from django.http import HttpResponseNotFound, JsonResponse from django.http import HttpResponseNotFound, JsonResponse
@ -27,36 +26,11 @@ def outbox(request, username):
except models.User.DoesNotExist: except models.User.DoesNotExist:
return HttpResponseNotFound() return HttpResponseNotFound()
# paginated list of messages
if request.GET.get('page'):
limit = 20
min_id = request.GET.get('min_id')
max_id = request.GET.get('max_id')
# filters for use in the django queryset min/max
filters = {}
# params for the outbox page id
params = {'page': 'true'}
if min_id is not None:
params['min_id'] = min_id
filters['id__gt'] = min_id
if max_id is not None:
params['max_id'] = max_id
filters['id__lte'] = max_id
page_id = user.outbox + '?' + urlencode(params)
statuses = models.Status.objects.filter(
user=user,
**filters
).select_subclasses().all()[:limit]
return JsonResponse(
activitypub.get_outbox_page(user, page_id, statuses, max_id, min_id)
)
# collection overview # collection overview
size = models.Status.objects.filter(user=user).count() return JsonResponse(
return JsonResponse(activitypub.get_outbox(user, size)) user.to_outbox(**request.GET),
encoder=activitypub.ActivityEncoder
)
def handle_account_search(query): def handle_account_search(query):
@ -83,7 +57,15 @@ def handle_account_search(query):
def handle_follow(user, to_follow): def handle_follow(user, to_follow):
''' someone local wants to follow someone ''' ''' someone local wants to follow someone '''
activity = activitypub.get_follow_request(user, to_follow) try:
relationship, _ = models.UserFollowRequest.objects.get_or_create(
user_subject=user,
user_object=to_follow,
)
except IntegrityError as err:
if err.__cause__.diag.constraint_name != 'userfollowrequest_unique':
raise
activity = relationship.to_activity()
broadcast(user, activity, direct_recipients=[to_follow]) broadcast(user, activity, direct_recipients=[to_follow])
@ -93,7 +75,7 @@ def handle_unfollow(user, to_unfollow):
user_subject=user, user_subject=user,
user_object=to_unfollow user_object=to_unfollow
) )
activity = activitypub.get_unfollow(relationship) activity = relationship.to_undo_activity(user)
broadcast(user, activity, direct_recipients=[to_unfollow]) broadcast(user, activity, direct_recipients=[to_unfollow])
to_unfollow.followers.remove(user) to_unfollow.followers.remove(user)
@ -105,25 +87,23 @@ def handle_accept(user, to_follow, follow_request):
follow_request.delete() follow_request.delete()
relationship.save() relationship.save()
activity = activitypub.get_accept(to_follow, follow_request) activity = relationship.to_accept_activity()
broadcast(to_follow, activity, privacy='direct', direct_recipients=[user]) broadcast(to_follow, activity, privacy='direct', direct_recipients=[user])
def handle_reject(user, to_follow, relationship): def handle_reject(user, to_follow, relationship):
''' a local user who managed follows rejects a follow request ''' ''' a local user who managed follows rejects a follow request '''
activity = relationship.to_reject_activity(user)
relationship.delete() relationship.delete()
activity = activitypub.get_reject(to_follow, relationship)
broadcast(to_follow, activity, privacy='direct', direct_recipients=[user]) broadcast(to_follow, activity, privacy='direct', direct_recipients=[user])
def handle_shelve(user, book, shelf): def handle_shelve(user, book, shelf):
''' a local user is getting a book put on their shelf ''' ''' a local user is getting a book put on their shelf '''
# update the database # update the database
models.ShelfBook(book=book, shelf=shelf, added_by=user).save() shelve = models.ShelfBook(book=book, shelf=shelf, added_by=user).save()
activity = activitypub.get_add(user, book, shelf) broadcast(user, shelve.to_add_activity(user))
broadcast(user, activity)
# tell the world about this cool thing that happened # tell the world about this cool thing that happened
verb = { verb = {
@ -155,20 +135,16 @@ def handle_shelve(user, book, shelf):
read.finish_date = datetime.now() read.finish_date = datetime.now()
read.save() read.save()
activity = activitypub.get_status(status) broadcast(user, status.to_create_activity(user))
create_activity = activitypub.get_create(user, activity)
broadcast(user, create_activity)
def handle_unshelve(user, book, shelf): def handle_unshelve(user, book, shelf):
''' a local user is getting a book put on their shelf ''' ''' a local user is getting a book put on their shelf '''
# update the database # update the database
row = models.ShelfBook.objects.get(book=book, shelf=shelf) row = models.ShelfBook.objects.get(book=book, shelf=shelf)
activity = row.to_remove_activity(user)
row.delete() row.delete()
activity = activitypub.get_remove(user, book, shelf)
broadcast(user, activity) broadcast(user, activity)
@ -185,11 +161,11 @@ def handle_import_books(user, items):
item.book = item.book.default_edition item.book = item.book.default_edition
if not item.book: if not item.book:
continue continue
_, created = models.ShelfBook.objects.get_or_create( shelf_book, created = models.ShelfBook.objects.get_or_create(
book=item.book, shelf=desired_shelf, added_by=user) book=item.book, shelf=desired_shelf, added_by=user)
if created: if created:
new_books.append(item.book) new_books.append(item.book)
activity = activitypub.get_add(user, item.book, desired_shelf) activity = shelf_book.to_add_activity(user)
broadcast(user, activity) broadcast(user, activity)
if item.rating or item.review: if item.rating or item.review:
@ -214,82 +190,62 @@ def handle_import_books(user, items):
status.status_type = 'Update' status.status_type = 'Update'
status.save() status.save()
create_activity = activitypub.get_create( broadcast(user, status.to_create_activity(user))
user, activitypub.get_status(status))
broadcast(user, create_activity)
return status return status
return None
def handle_rate(user, book, rating): def handle_rate(user, book, rating):
''' a review that's just a rating ''' ''' a review that's just a rating '''
builder = create_rating builder = create_rating
fr_serializer = activitypub.get_rating handle_status(user, book, builder, rating)
ap_serializer = activitypub.get_rating_note
handle_status(user, book, builder, fr_serializer, ap_serializer, rating)
def handle_review(user, book, name, content, rating): def handle_review(user, book, name, content, rating):
''' post a review ''' ''' post a review '''
# validated and saves the review in the database so it has an id # validated and saves the review in the database so it has an id
builder = create_review builder = create_review
fr_serializer = activitypub.get_review handle_status(user, book, builder, name, content, rating)
ap_serializer = activitypub.get_review_article
handle_status(
user, book, builder, fr_serializer,
ap_serializer, name, content, rating)
def handle_quotation(user, book, content, quote): def handle_quotation(user, book, content, quote):
''' post a review ''' ''' post a review '''
# validated and saves the review in the database so it has an id # validated and saves the review in the database so it has an id
builder = create_quotation builder = create_quotation
fr_serializer = activitypub.get_quotation handle_status(user, book, builder, content, quote)
ap_serializer = activitypub.get_quotation_article
handle_status(
user, book, builder, fr_serializer, ap_serializer, content, quote)
def handle_comment(user, book, content): def handle_comment(user, book, content):
''' post a comment ''' ''' post a comment '''
# validated and saves the review in the database so it has an id # validated and saves the review in the database so it has an id
builder = create_comment builder = create_comment
fr_serializer = activitypub.get_comment handle_status(user, book, builder, content)
ap_serializer = activitypub.get_comment_article
handle_status(
user, book, builder, fr_serializer, ap_serializer, content)
def handle_status(user, book_id, \ def handle_status(user, book_id, builder, *args):
builder, fr_serializer, ap_serializer, *args):
''' generic handler for statuses ''' ''' generic handler for statuses '''
book = models.Edition.objects.get(id=book_id) book = models.Edition.objects.get(id=book_id)
status = builder(user, book, *args) status = builder(user, book, *args)
activity = fr_serializer(status) broadcast(user, status.to_create_activity(user), software='fedireads')
create_activity = activitypub.get_create(user, activity)
broadcast(user, create_activity, software='fedireads')
# re-format the activity for non-fedireads servers # re-format the activity for non-fedireads servers
remote_activity = ap_serializer(status) remote_activity = status.to_create_activity(user, pure=True)
remote_create_activity = activitypub.get_create(user, remote_activity)
broadcast(user, remote_create_activity, software='other') broadcast(user, remote_activity, software='other')
def handle_tag(user, book, name): def handle_tag(user, book, name):
''' tag a book ''' ''' tag a book '''
tag = create_tag(user, book, name) tag = create_tag(user, book, name)
tag_activity = activitypub.get_add_tag(tag) broadcast(user, tag.to_add_activity(user))
broadcast(user, tag_activity)
def handle_untag(user, book, name): def handle_untag(user, book, name):
''' tag a book ''' ''' tag a book '''
book = models.Book.objects.get(id=book) book = models.Book.objects.get(id=book)
tag = models.Tag.objects.get(name=name, book=book, user=user) tag = models.Tag.objects.get(name=name, book=book, user=user)
tag_activity = activitypub.get_remove_tag(tag) tag_activity = tag.to_remove_activity(user)
tag.delete() tag.delete()
broadcast(user, tag_activity) broadcast(user, tag_activity)
@ -306,10 +262,8 @@ def handle_reply(user, review, content):
related_user=user, related_user=user,
related_status=reply, related_status=reply,
) )
reply_activity = activitypub.get_status(reply)
create_activity = activitypub.get_create(user, reply_activity)
broadcast(user, create_activity) broadcast(user, reply.to_create_activity(user))
def handle_favorite(user, status): def handle_favorite(user, status):
@ -323,7 +277,7 @@ def handle_favorite(user, status):
# you already fav'ed that # you already fav'ed that
return return
fav_activity = activitypub.get_favorite(favorite) fav_activity = favorite.to_activity()
broadcast( broadcast(
user, fav_activity, privacy='direct', direct_recipients=[status.user]) user, fav_activity, privacy='direct', direct_recipients=[status.user])
@ -339,7 +293,7 @@ def handle_unfavorite(user, status):
# can't find that status, idk # can't find that status, idk
return return
fav_activity = activitypub.get_unfavorite(favorite) fav_activity = activitypub.Undo(actor=user, object=favorite)
broadcast(user, fav_activity, direct_recipients=[status.user]) broadcast(user, fav_activity, direct_recipients=[status.user])
@ -355,19 +309,15 @@ def handle_boost(user, status):
) )
boost.save() boost.save()
boost_activity = activitypub.get_boost(boost) boost_activity = boost.to_activity()
broadcast(user, boost_activity) broadcast(user, boost_activity)
def handle_update_book(user, book): def handle_update_book(user, book):
''' broadcast the news about our book ''' ''' broadcast the news about our book '''
book_activity = activitypub.get_book(book) broadcast(user, book.to_update_activity(user))
update_activity = activitypub.get_update(user, book_activity)
broadcast(user, update_activity)
def handle_update_user(user): def handle_update_user(user):
''' broadcast editing a user's profile ''' ''' broadcast editing a user's profile '''
actor = activitypub.get_actor(user) broadcast(user, user.to_update_activity())
update_activity = activitypub.get_update(user, actor)
broadcast(user, update_activity)

View file

@ -6,8 +6,7 @@ import requests
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.db import transaction from django.db import transaction
from fedireads import models from fedireads import activitypub, models
from fedireads.status import create_review_from_activity
def get_or_create_remote_user(actor): def get_or_create_remote_user(actor):
@ -33,8 +32,9 @@ def get_or_create_remote_user(actor):
get_remote_reviews(user) get_remote_reviews(user)
return user return user
def fetch_user_data(actor): def fetch_user_data(actor):
# load the user's info from the actor url ''' load the user's info from the actor url '''
response = requests.get( response = requests.get(
actor, actor,
headers={'Accept': 'application/activity+json'} headers={'Accept': 'application/activity+json'}
@ -51,50 +51,17 @@ def fetch_user_data(actor):
def create_remote_user(data): def create_remote_user(data):
''' parse the activitypub actor data into a user ''' ''' parse the activitypub actor data into a user '''
actor = data['id'] actor = activitypub.Person(**data)
actor_parts = urlparse(actor) return actor.to_model(models.User)
# the webfinger format for the username.
username = '%s@%s' % (actor_parts.path.split('/')[-1], actor_parts.netloc)
shared_inbox = data.get('endpoints').get('sharedInbox') if \
data.get('endpoints') else None
# throws a key error if it can't find any of these fields
return models.User.objects.create_user(
username,
'', '', # email and passwords are left blank
remote_id=actor,
name=data.get('name'),
summary=data.get('summary'),
inbox=data['inbox'], #fail if there's no inbox
outbox=data['outbox'], # fail if there's no outbox
shared_inbox=shared_inbox,
public_key=data.get('publicKey').get('publicKeyPem'),
local=False,
fedireads_user=data.get('fedireadsUser', False),
manually_approves_followers=data.get(
'manuallyApprovesFollowers', False),
)
def refresh_remote_user(user): def refresh_remote_user(user):
''' get updated user data from its home instance '''
data = fetch_user_data(user.remote_id) data = fetch_user_data(user.remote_id)
shared_inbox = data.get('endpoints').get('sharedInbox') if \ activity = activitypub.Person(**data)
data.get('endpoints') else None activity.to_model(models.User, instance=user)
# TODO - I think dataclasses change will mean less repetition here later.
user.name = data.get('name')
user.summary = data.get('summary')
user.inbox = data['inbox'] #fail if there's no inbox
user.outbox = data['outbox'] # fail if there's no outbox
user.shared_inbox = shared_inbox
user.public_key = data.get('publicKey').get('publicKeyPem')
user.local = False
user.fedireads_user = data.get('fedireadsUser', False)
user.manually_approves_followers = data.get(
'manuallyApprovesFollowers', False)
user.save()
def get_avatar(data): def get_avatar(data):
''' find the icon attachment and load the image from the remote sever ''' ''' find the icon attachment and load the image from the remote sever '''
@ -122,7 +89,7 @@ def get_remote_reviews(user):
# TODO: pagination? # TODO: pagination?
for status in data['orderedItems']: for status in data['orderedItems']:
if status.get('fedireadsType') == 'Review': if status.get('fedireadsType') == 'Review':
create_review_from_activity(user, status) activitypub.Review(**status).to_model(models.Review)
def get_or_create_remote_server(domain): def get_or_create_remote_server(domain):

View file

@ -39,7 +39,7 @@ def make_signature(sender, destination, date, digest):
return ','.join('%s="%s"' % (k, v) for (k, v) in signature.items()) return ','.join('%s="%s"' % (k, v) for (k, v) in signature.items())
def make_digest(data): def make_digest(data):
return 'SHA-256=' + b64encode(hashlib.sha256(data).digest()).decode('utf-8') return 'SHA-256=' + b64encode(hashlib.sha256(data.encode('utf-8')).digest()).decode('utf-8')
def verify_digest(request): def verify_digest(request):
algorithm, digest = request.headers['digest'].split('=', 1) algorithm, digest = request.headers['digest'].split('=', 1)

View file

@ -6,23 +6,6 @@ from fedireads.books_manager import get_or_create_book
from fedireads.sanitize_html import InputHtmlParser from fedireads.sanitize_html import InputHtmlParser
def create_review_from_activity(author, activity):
''' parse an activity json blob into a status '''
book_id = activity['inReplyToBook']
book = get_or_create_book(book_id)
name = activity.get('name')
rating = activity.get('rating')
content = activity.get('content')
published = activity.get('published')
remote_id = activity['id']
review = create_review(author, book, name, content, rating)
review.published_date = published
review.remote_id = remote_id
review.save()
return review
def create_rating(user, book, rating): def create_rating(user, book, rating):
''' a review that's just a rating ''' ''' a review that's just a rating '''
if not rating or rating < 1 or rating > 5: if not rating or rating < 1 or rating > 5:
@ -111,50 +94,6 @@ def create_comment(user, book, content):
) )
def create_status_from_activity(author, activity):
''' parse a status object out of an activity json blob '''
content = activity.get('content')
reply_parent_id = activity.get('inReplyTo')
reply_parent = get_status(reply_parent_id)
remote_id = activity['id']
if models.Status.objects.filter(remote_id=remote_id).count():
return None
status = create_status(author, content, reply_parent=reply_parent,
remote_id=remote_id)
status.published_date = activity.get('published')
status.save()
return status
def create_favorite_from_activity(user, activity):
''' create a new favorite entry '''
status = get_status(activity['object'])
remote_id = activity['id']
try:
return models.Favorite.objects.create(
status=status,
user=user,
remote_id=remote_id,
)
except IntegrityError:
return models.Favorite.objects.get(status=status, user=user)
def create_boost_from_activity(user, activity):
''' create a new boost activity '''
status = get_status(activity['object'])
remote_id = activity['id']
try:
return models.Boost.objects.create(
status=status,
user=user,
remote_id=remote_id,
)
except IntegrityError:
return models.Boost.objects.get(status=status, user=user)
def get_status(remote_id): def get_status(remote_id):
''' find a status in the database ''' ''' find a status in the database '''
return models.Status.objects.select_subclasses().filter( return models.Status.objects.select_subclasses().filter(

View file

@ -0,0 +1 @@
from . import *

View file

@ -0,0 +1,27 @@
import datetime
from django.test import TestCase
from fedireads import models
class Author(TestCase):
def setUp(self):
self.book = models.Edition.objects.create(
title='Example Edition',
remote_id='https://example.com/book/1',
)
self.author = models.Author.objects.create(
name='Author fullname',
first_name='Auth',
last_name='Or',
aliases=['One', 'Two'],
bio='bio bio bio',
)
def test_serialize_model(self):
activity = self.author.to_activity()
self.assertEqual(activity['id'], self.author.remote_id)
self.assertIsInstance(activity['aliases'], list)
self.assertEqual(activity['aliases'], ['One', 'Two'])
self.assertEqual(activity['name'], 'Author fullname')

View file

@ -0,0 +1,32 @@
import json
import pathlib
from django.test import TestCase
from fedireads import activitypub, models
class Person(TestCase):
def setUp(self):
self.user = models.User.objects.create_user(
'rat', 'rat@rat.com', 'ratword',
)
datafile = pathlib.Path(__file__).parent.joinpath(
'../data/ap_user.json'
)
self.user_data = json.loads(datafile.read_bytes())
def test_load_user_data(self):
activity = activitypub.Person(**self.user_data)
self.assertEqual(activity.id, 'https://example.com/user/mouse')
self.assertEqual(activity.preferredUsername, 'mouse')
self.assertEqual(activity.type, 'Person')
def test_serialize_model(self):
activity = self.user.to_activity()
self.assertEqual(activity['id'], self.user.remote_id)
self.assertEqual(
activity['endpoints'],
{'sharedInbox': self.user.shared_inbox}
)

View file

@ -0,0 +1,46 @@
import json
import pathlib
from django.test import TestCase
from fedireads import activitypub, models
class Quotation(TestCase):
''' we have hecka ways to create statuses '''
def setUp(self):
self.user = models.User.objects.create_user(
'mouse', 'mouse@mouse.mouse', 'mouseword',
local=False,
inbox='https://example.com/user/mouse/inbox',
outbox='https://example.com/user/mouse/outbox',
remote_id='https://example.com/user/mouse',
)
self.book = models.Edition.objects.create(
title='Example Edition',
remote_id='https://example.com/book/1',
)
datafile = pathlib.Path(__file__).parent.joinpath(
'../data/ap_quotation.json'
)
self.status_data = json.loads(datafile.read_bytes())
def test_quotation_activity(self):
quotation = activitypub.Quotation(**self.status_data)
self.assertEqual(quotation.type, 'Quotation')
self.assertEqual(
quotation.id, 'https://example.com/user/mouse/quotation/13')
self.assertEqual(quotation.content, 'commentary')
self.assertEqual(quotation.quote, 'quote body')
self.assertEqual(quotation.inReplyToBook, 'https://example.com/book/1')
self.assertEqual(
quotation.published, '2020-05-10T02:38:31.150343+00:00')
def test_activity_to_model(self):
activity = activitypub.Quotation(**self.status_data)
quotation = activity.to_model(models.Quotation)
self.assertEqual(quotation.book, self.book)
self.assertEqual(quotation.user, self.user)

View file

@ -0,0 +1,29 @@
{
"id": "https://example.com/user/mouse/comment/6",
"url": "https://example.com/user/mouse/comment/6",
"inReplyTo": null,
"published": "2020-05-08T23:45:44.768012+00:00",
"attributedTo": "https://example.com/user/mouse",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [
"https://example.com/user/mouse/followers"
],
"sensitive": null,
"content": "commentary",
"type": "Comment",
"attachment": [],
"replies": {
"id": "https://example.com/user/mouse/comment/6/replies",
"type": "Collection",
"first": {
"type": "CollectionPage",
"next": "https://example.com/user/mouse/comment/6/replies?only_other_accounts=true&page=true",
"partOf": "https://example.com/user/mouse/comment/6/replies",
"items": []
}
},
"inReplyToBook": "https://example.com/book/1"
}

View file

@ -0,0 +1,36 @@
{
"id": "https://example.com/user/mouse/quotation/13",
"url": "https://example.com/user/mouse/quotation/13",
"inReplyTo": null,
"published": "2020-05-10T02:38:31.150343+00:00",
"attributedTo": "https://example.com/user/mouse",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [
"https://example.com/user/mouse/followers"
],
"sensitive": false,
"content": "commentary",
"type": "Quotation",
"attachment": [
{
"type": "Document",
"mediaType": "image//images/covers/2b4e4712-5a4d-4ac1-9df4-634cc9c7aff3jpg",
"url": "https://example.com/images/covers/2b4e4712-5a4d-4ac1-9df4-634cc9c7aff3jpg",
"name": "Cover of \"This Is How You Lose the Time War\""
}
],
"replies": {
"id": "https://example.com/user/mouse/quotation/13/replies",
"type": "Collection",
"first": {
"type": "CollectionPage",
"next": "https://example.com/user/mouse/quotation/13/replies?only_other_accounts=true&page=true",
"partOf": "https://example.com/user/mouse/quotation/13/replies",
"items": []
}
},
"inReplyToBook": "https://example.com/book/1",
"quote": "quote body"
}

View file

@ -19,32 +19,24 @@ class Status(TestCase):
def test_status(self): def test_status(self):
status = models.Status.objects.first() status = models.Status.objects.first()
self.assertEqual(status.status_type, 'Note')
self.assertEqual(status.activity_type, 'Note')
expected_id = 'https://%s/user/mouse/status/%d' % \ expected_id = 'https://%s/user/mouse/status/%d' % \
(settings.DOMAIN, status.id) (settings.DOMAIN, status.id)
self.assertEqual(status.remote_id, expected_id) self.assertEqual(status.remote_id, expected_id)
def test_comment(self): def test_comment(self):
comment = models.Comment.objects.first() comment = models.Comment.objects.first()
self.assertEqual(comment.status_type, 'Comment')
self.assertEqual(comment.activity_type, 'Note')
expected_id = 'https://%s/user/mouse/comment/%d' % \ expected_id = 'https://%s/user/mouse/comment/%d' % \
(settings.DOMAIN, comment.id) (settings.DOMAIN, comment.id)
self.assertEqual(comment.remote_id, expected_id) self.assertEqual(comment.remote_id, expected_id)
def test_quotation(self): def test_quotation(self):
quotation = models.Quotation.objects.first() quotation = models.Quotation.objects.first()
self.assertEqual(quotation.status_type, 'Quotation')
self.assertEqual(quotation.activity_type, 'Note')
expected_id = 'https://%s/user/mouse/quotation/%d' % \ expected_id = 'https://%s/user/mouse/quotation/%d' % \
(settings.DOMAIN, quotation.id) (settings.DOMAIN, quotation.id)
self.assertEqual(quotation.remote_id, expected_id) self.assertEqual(quotation.remote_id, expected_id)
def test_review(self): def test_review(self):
review = models.Review.objects.first() review = models.Review.objects.first()
self.assertEqual(review.status_type, 'Review')
self.assertEqual(review.activity_type, 'Article')
expected_id = 'https://%s/user/mouse/review/%d' % \ expected_id = 'https://%s/user/mouse/review/%d' % \
(settings.DOMAIN, review.id) (settings.DOMAIN, review.id)
self.assertEqual(review.remote_id, expected_id) self.assertEqual(review.remote_id, expected_id)

View file

@ -16,41 +16,3 @@ class Comment(TestCase):
comment = status_builder.create_comment( comment = status_builder.create_comment(
self.user, self.book, 'commentary') self.user, self.book, 'commentary')
self.assertEqual(comment.content, 'commentary') self.assertEqual(comment.content, 'commentary')
def test_comment_from_activity(self):
activity = {
"id": "https://example.com/user/mouse/comment/6",
"url": "https://example.com/user/mouse/comment/6",
"inReplyTo": None,
"published": "2020-05-08T23:45:44.768012+00:00",
"attributedTo": "https://example.com/user/mouse",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [
"https://example.com/user/mouse/followers"
],
"sensitive": False,
"content": "commentary",
"type": "Note",
"attachment": [],
"replies": {
"id": "https://example.com/user/mouse/comment/6/replies",
"type": "Collection",
"first": {
"type": "CollectionPage",
"next": "https://example.com/user/mouse/comment/6/replies?only_other_accounts=true&page=true",
"partOf": "https://example.com/user/mouse/comment/6/replies",
"items": []
}
},
"inReplyToBook": self.book.remote_id,
"fedireadsType": "Comment"
}
comment = status_builder.create_comment_from_activity(
self.user, activity)
self.assertEqual(comment.content, 'commentary')
self.assertEqual(comment.book, self.book)
self.assertEqual(
comment.published_date, '2020-05-08T23:45:44.768012+00:00')

View file

@ -1,6 +1,8 @@
from django.test import TestCase from django.test import TestCase
import json
import pathlib
from fedireads import models from fedireads import activitypub, models
from fedireads import status as status_builder from fedireads import status as status_builder
@ -8,8 +10,13 @@ class Quotation(TestCase):
''' we have hecka ways to create statuses ''' ''' we have hecka ways to create statuses '''
def setUp(self): def setUp(self):
self.user = models.User.objects.create_user( self.user = models.User.objects.create_user(
'mouse', 'mouse@mouse.mouse', 'mouseword') 'mouse', 'mouse@mouse.mouse', 'mouseword',
self.book = models.Edition.objects.create(title='Example Edition') remote_id='https://example.com/user/mouse'
)
self.book = models.Edition.objects.create(
title='Example Edition',
remote_id='https://example.com/book/1',
)
def test_create_quotation(self): def test_create_quotation(self):
@ -17,50 +24,3 @@ class Quotation(TestCase):
self.user, self.book, 'commentary', 'a quote') self.user, self.book, 'commentary', 'a quote')
self.assertEqual(quotation.quote, 'a quote') self.assertEqual(quotation.quote, 'a quote')
self.assertEqual(quotation.content, 'commentary') self.assertEqual(quotation.content, 'commentary')
def test_quotation_from_activity(self):
activity = {
'id': 'https://example.com/user/mouse/quotation/13',
'url': 'https://example.com/user/mouse/quotation/13',
'inReplyTo': None,
'published': '2020-05-10T02:38:31.150343+00:00',
'attributedTo': 'https://example.com/user/mouse',
'to': [
'https://www.w3.org/ns/activitystreams#Public'
],
'cc': [
'https://example.com/user/mouse/followers'
],
'sensitive': False,
'content': 'commentary',
'type': 'Note',
'attachment': [
{
'type': 'Document',
'mediaType': 'image//images/covers/2b4e4712-5a4d-4ac1-9df4-634cc9c7aff3jpg',
'url': 'https://example.com/images/covers/2b4e4712-5a4d-4ac1-9df4-634cc9c7aff3jpg',
'name': 'Cover of \'This Is How You Lose the Time War\''
}
],
'replies': {
'id': 'https://example.com/user/mouse/quotation/13/replies',
'type': 'Collection',
'first': {
'type': 'CollectionPage',
'next': 'https://example.com/user/mouse/quotation/13/replies?only_other_accounts=true&page=true',
'partOf': 'https://example.com/user/mouse/quotation/13/replies',
'items': []
}
},
'inReplyToBook': self.book.remote_id,
'fedireadsType': 'Quotation',
'quote': 'quote body'
}
quotation = status_builder.create_quotation_from_activity(
self.user, activity)
self.assertEqual(quotation.content, 'commentary')
self.assertEqual(quotation.quote, 'quote body')
self.assertEqual(quotation.book, self.book)
self.assertEqual(
quotation.published_date, '2020-05-10T02:38:31.150343+00:00')

View file

@ -37,45 +37,3 @@ class Review(TestCase):
self.assertEqual(review.name, 'review name') self.assertEqual(review.name, 'review name')
self.assertEqual(review.content, 'content') self.assertEqual(review.content, 'content')
self.assertEqual(review.rating, None) self.assertEqual(review.rating, None)
def test_review_from_activity(self):
activity = {
'id': 'https://example.com/user/mouse/review/9',
'url': 'https://example.com/user/mouse/review/9',
'inReplyTo': None,
'published': '2020-05-04T00:00:00.000000+00:00',
'attributedTo': 'https://example.com/user/mouse',
'to': [
'https://www.w3.org/ns/activitystreams#Public'
],
'cc': [
'https://example.com/user/mouse/followers'
],
'sensitive': False,
'content': 'review content',
'type': 'Article',
'attachment': [],
'replies': {
'id': 'https://example.com/user/mouse/review/9/replies',
'type': 'Collection',
'first': {
'type': 'CollectionPage',
'next': 'https://example.com/user/mouse/review/9/replies?only_other_accounts=true&page=true',
'partOf': 'https://example.com/user/mouse/review/9/replies',
'items': []
}
},
'inReplyToBook': self.book.remote_id,
'fedireadsType': 'Review',
'name': 'review title',
'rating': 3
}
review = status_builder.create_review_from_activity(
self.user, activity)
self.assertEqual(review.content, 'review content')
self.assertEqual(review.name, 'review title')
self.assertEqual(review.rating, 3)
self.assertEqual(review.book, self.book)
self.assertEqual(
review.published_date, '2020-05-04T00:00:00.000000+00:00')

View file

@ -8,7 +8,12 @@ class Status(TestCase):
''' we have hecka ways to create statuses ''' ''' we have hecka ways to create statuses '''
def setUp(self): def setUp(self):
self.user = models.User.objects.create_user( self.user = models.User.objects.create_user(
'mouse', 'mouse@mouse.mouse', 'mouseword') 'mouse', 'mouse@mouse.mouse', 'mouseword',
local=False,
inbox='https://example.com/user/mouse/inbox',
outbox='https://example.com/user/mouse/outbox',
remote_id='https://example.com/user/mouse'
)
def test_create_status(self): def test_create_status(self):
@ -21,45 +26,3 @@ class Status(TestCase):
self.user, content, reply_parent=status) self.user, content, reply_parent=status)
self.assertEqual(reply.content, content) self.assertEqual(reply.content, content)
self.assertEqual(reply.reply_parent, status) self.assertEqual(reply.reply_parent, status)
def test_create_status_from_activity(self):
book = models.Edition.objects.create(title='Example Edition')
review = status_builder.create_review(
self.user, book, 'review name', 'content', 5)
activity = {
'id': 'https://example.com/user/mouse/status/12',
'url': 'https://example.com/user/mouse/status/12',
'inReplyTo': review.remote_id,
'published': '2020-05-10T02:15:59.635557+00:00',
'attributedTo': 'https://example.com/user/mouse',
'to': [
'https://www.w3.org/ns/activitystreams#Public'
],
'cc': [
'https://example.com/user/mouse/followers'
],
'sensitive': False,
'content': 'reply to status',
'type': 'Note',
'attachment': [],
'replies': {
'id': 'https://example.com/user/mouse/status/12/replies',
'type': 'Collection',
'first': {
'type': 'CollectionPage',
'next': 'https://example.com/user/mouse/status/12/replies?only_other_accounts=true&page=true',
'partOf': 'https://example.com/user/mouse/status/12/replies',
'items': []
}
}
}
status = status_builder.create_status_from_activity(
self.user, activity)
self.assertEqual(status.reply_parent, review)
self.assertEqual(status.content, 'reply to status')
self.assertEqual(
status.published_date,
'2020-05-10T02:15:59.635557+00:00'
)

View file

@ -0,0 +1,59 @@
import json
import pathlib
from django.test import TestCase
from fedireads import models, incoming
class Favorite(TestCase):
''' not too much going on in the books model but here we are '''
def setUp(self):
self.remote_user = models.User.objects.create_user(
'rat', 'rat@rat.com', 'ratword',
local=False,
remote_id='https://example.com/users/rat',
inbox='https://example.com/users/rat/inbox',
outbox='https://example.com/users/rat/outbox',
)
self.local_user = models.User.objects.create_user(
'mouse', 'mouse@mouse.com', 'mouseword',
remote_id='http://local.com/user/mouse')
self.status = models.Status.objects.create(
user=self.local_user,
content='Test status',
remote_id='http://local.com/status/1',
)
datafile = pathlib.Path(__file__).parent.joinpath(
'data/ap_user.json'
)
self.user_data = json.loads(datafile.read_bytes())
def test_handle_favorite(self):
activity = {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': 'http://example.com/activity/1',
'type': 'Create',
'actor': 'https://example.com/users/rat',
'published': 'Mon, 25 May 2020 19:31:20 GMT',
'to': ['https://example.com/user/rat/followers'],
'cc': ['https://www.w3.org/ns/activitystreams#Public'],
'object': {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': 'http://example.com/fav/1',
'type': 'Like',
'actor': 'https://example.com/users/rat',
'object': 'http://local.com/status/1',
},
'signature': {}
}
result = incoming.handle_favorite(activity)
fav = models.Favorite.objects.get(remote_id='http://example.com/fav/1')
self.assertEqual(fav.status, self.status)
self.assertEqual(fav.remote_id, 'http://example.com/fav/1')
self.assertEqual(fav.user, self.remote_user)

View file

@ -1,6 +1,6 @@
from django.test import TestCase
import json import json
import pathlib import pathlib
from django.test import TestCase
from fedireads import models, remote_user from fedireads import models, remote_user
@ -9,29 +9,62 @@ class RemoteUser(TestCase):
''' not too much going on in the books model but here we are ''' ''' not too much going on in the books model but here we are '''
def setUp(self): def setUp(self):
self.remote_user = models.User.objects.create_user( self.remote_user = models.User.objects.create_user(
'mouse', 'mouse@mouse.com', 'mouseword', 'rat', 'rat@rat.com', 'ratword',
local=False, local=False,
remote_id='https://example.com/users/mouse', remote_id='https://example.com/users/rat',
inbox='https://example.com/users/mouse/inbox', inbox='https://example.com/users/rat/inbox',
outbox='https://example.com/users/mouse/outbox', outbox='https://example.com/users/rat/outbox',
) )
datafile = pathlib.Path(__file__).parent.joinpath(
'data/ap_user.json'
)
self.user_data = json.loads(datafile.read_bytes())
def test_get_remote_user(self): def test_get_remote_user(self):
actor = 'https://example.com/users/mouse' actor = 'https://example.com/users/rat'
user = remote_user.get_or_create_remote_user(actor) user = remote_user.get_or_create_remote_user(actor)
self.assertEqual(user, self.remote_user) self.assertEqual(user, self.remote_user)
def test_create_remote_user(self): def test_create_remote_user(self):
datafile = pathlib.Path(__file__).parent.joinpath('data/ap_user.json') user = remote_user.create_remote_user(self.user_data)
data = json.loads(datafile.read_bytes()) self.assertFalse(user.local)
user = remote_user.create_remote_user(data) self.assertEqual(user.remote_id, 'https://example.com/user/mouse')
self.assertEqual(user.username, 'mouse@example.com') self.assertEqual(user.username, 'mouse@example.com')
self.assertEqual(user.name, 'MOUSE?? MOUSE!!') self.assertEqual(user.name, 'MOUSE?? MOUSE!!')
self.assertEqual(user.inbox, 'https://example.com/user/mouse/inbox') self.assertEqual(user.inbox, 'https://example.com/user/mouse/inbox')
self.assertEqual(user.outbox, 'https://example.com/user/mouse/outbox') self.assertEqual(user.outbox, 'https://example.com/user/mouse/outbox')
self.assertEqual(user.shared_inbox, 'https://example.com/inbox') self.assertEqual(user.shared_inbox, 'https://example.com/inbox')
self.assertEqual(user.public_key, data['publicKey']['publicKeyPem']) self.assertEqual(
user.public_key,
self.user_data['publicKey']['publicKeyPem']
)
self.assertEqual(user.local, False) self.assertEqual(user.local, False)
self.assertEqual(user.fedireads_user, True) self.assertEqual(user.fedireads_user, True)
self.assertEqual(user.manually_approves_followers, False) self.assertEqual(user.manually_approves_followers, False)
def test_create_remote_user_missing_inbox(self):
del self.user_data['inbox']
self.assertRaises(
TypeError,
remote_user.create_remote_user,
self.user_data
)
def test_create_remote_user_missing_outbox(self):
del self.user_data['outbox']
self.assertRaises(
TypeError,
remote_user.create_remote_user,
self.user_data
)
def test_create_remote_user_default_fields(self):
del self.user_data['manuallyApprovesFollowers']
user = remote_user.create_remote_user(self.user_data)
self.assertEqual(user.manually_approves_followers, False)

View file

@ -10,12 +10,17 @@ from django.test import TestCase, Client
from django.utils.http import http_date from django.utils.http import http_date
from fedireads.models import User from fedireads.models import User
from fedireads.activitypub import get_follow_request from fedireads.activitypub import Follow
from fedireads.settings import DOMAIN from fedireads.settings import DOMAIN
from fedireads.signatures import create_key_pair, make_signature, make_digest from fedireads.signatures import create_key_pair, make_signature, make_digest
def get_follow_data(follower, followee): def get_follow_data(follower, followee):
return json.dumps(get_follow_request(follower, followee)).encode('utf-8') follow_activity = Follow(
id='https://test.com/user/follow/id',
actor=follower.remote_id,
object=followee.remote_id,
).serialize()
return json.dumps(follow_activity)
Sender = namedtuple('Sender', ('remote_id', 'private_key', 'public_key')) Sender = namedtuple('Sender', ('remote_id', 'private_key', 'public_key'))

View file

@ -10,7 +10,11 @@ username_regex = r'(?P<username>[\w\-_]+@[\w\-\_\.]+)'
localname_regex = r'(?P<username>[\w\-_]+)' localname_regex = r'(?P<username>[\w\-_]+)'
user_path = r'^user/%s' % username_regex user_path = r'^user/%s' % username_regex
local_user_path = r'^user/%s' % localname_regex local_user_path = r'^user/%s' % localname_regex
status_path = r'%s/(status|review|comment|quotation)/(?P<status_id>\d+)' % local_user_path
status_types = ['status', 'review', 'comment', 'quotation', 'boost']
status_path = r'%s/(%s)/(?P<status_id>\d+)' % \
(local_user_path, '|'.join(status_types))
book_path = r'^book/(?P<book_id>\d+)' book_path = r'^book/(?P<book_id>\d+)'
handler404 = 'fedireads.views.not_found_page' handler404 = 'fedireads.views.not_found_page'
@ -67,6 +71,7 @@ urlpatterns = [
re_path(r'^editions/(?P<work_id>\d+)/?$', views.editions_page), re_path(r'^editions/(?P<work_id>\d+)/?$', views.editions_page),
re_path(r'^author/(?P<author_id>[\w\-]+)(.json)?/?$', views.author_page), re_path(r'^author/(?P<author_id>[\w\-]+)(.json)?/?$', views.author_page),
# TODO: tag needs a .json path
re_path(r'^tag/(?P<tag_id>.+)/?$', views.tag_page), re_path(r'^tag/(?P<tag_id>.+)/?$', views.tag_page),
re_path(r'^%s/shelf/(?P<shelf_identifier>[\w-]+)(.json)?/?$' % user_path, views.shelf_page), re_path(r'^%s/shelf/(?P<shelf_identifier>[\w-]+)(.json)?/?$' % user_path, views.shelf_page),
re_path(r'^%s/shelf/(?P<shelf_identifier>[\w-]+)(.json)?/?$' % local_user_path, views.shelf_page), re_path(r'^%s/shelf/(?P<shelf_identifier>[\w-]+)(.json)?/?$' % local_user_path, views.shelf_page),

View file

@ -9,7 +9,8 @@ from django.core.exceptions import PermissionDenied
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from fedireads import activitypub, outgoing from fedireads import outgoing
from fedireads.activitypub import ActivityEncoder
from fedireads import forms, models, books_manager from fedireads import forms, models, books_manager
from fedireads import goodreads_import from fedireads import goodreads_import
from fedireads.tasks import app from fedireads.tasks import app
@ -222,8 +223,9 @@ def about_page(request):
} }
return TemplateResponse(request, 'about.html', data) return TemplateResponse(request, 'about.html', data)
def invite_page(request, code): def invite_page(request, code):
''' Handle invites. ''' ''' endpoint for sending invites '''
try: try:
invite = models.SiteInvite.objects.get(code=code) invite = models.SiteInvite.objects.get(code=code)
if not invite.valid(): if not invite.valid():
@ -240,6 +242,7 @@ def invite_page(request, code):
@login_required @login_required
def manage_invites(request): def manage_invites(request):
''' invite management page '''
data = { data = {
'invites': models.SiteInvite.objects.filter(user=request.user), 'invites': models.SiteInvite.objects.filter(user=request.user),
'form': forms.CreateInviteForm(), 'form': forms.CreateInviteForm(),
@ -270,7 +273,7 @@ def user_page(request, username, subpage=None):
if is_api_request(request): if is_api_request(request):
# we have a json request # we have a json request
return JsonResponse(user.activitypub_serialize) return JsonResponse(user.to_activity(), encoder=ActivityEncoder)
# otherwise we're at a UI view # otherwise we're at a UI view
# TODO: change display with privacy and authentication considerations # TODO: change display with privacy and authentication considerations
@ -308,10 +311,7 @@ def followers_page(request, username):
return HttpResponseNotFound() return HttpResponseNotFound()
if is_api_request(request): if is_api_request(request):
user = models.User.objects.get(localname=username) return JsonResponse(user.to_followers_activity(**request.GET))
followers = user.followers
page = request.GET.get('page')
return JsonResponse(activitypub.get_followers(user, page, followers))
return user_page(request, username, subpage='followers') return user_page(request, username, subpage='followers')
@ -328,10 +328,7 @@ def following_page(request, username):
return HttpResponseNotFound() return HttpResponseNotFound()
if is_api_request(request): if is_api_request(request):
user = models.User.objects.get(localname=username) return JsonResponse(user.to_following_activity(**request.GET))
following = user.following
page = request.GET.get('page')
return JsonResponse(activitypub.get_following(user, page, following))
return user_page(request, username, subpage='following') return user_page(request, username, subpage='following')
@ -361,7 +358,7 @@ def status_page(request, username, status_id):
return HttpResponseNotFound() return HttpResponseNotFound()
if is_api_request(request): if is_api_request(request):
return JsonResponse(status.activitypub_serialize) return JsonResponse(status.to_activity(), encoder=ActivityEncoder)
data = { data = {
'status': status, 'status': status,
@ -382,28 +379,10 @@ def replies_page(request, username, status_id):
if status.user.localname != username: if status.user.localname != username:
return HttpResponseNotFound() return HttpResponseNotFound()
replies = models.Status.objects.filter( return JsonResponse(
reply_parent=status, status.to_replies(**request.GET),
).select_subclasses() encoder=ActivityEncoder
if request.GET.get('only_other_accounts'):
replies = replies.filter(
~Q(user=status.user)
) )
else:
replies = replies.filter(user=status.user)
if request.GET.get('page'):
min_id = request.GET.get('min_id')
if min_id:
replies = replies.filter(id__gt=min_id)
max_id = request.GET.get('max_id')
if max_id:
replies = replies.filter(id__lte=max_id)
activity = activitypub.get_replies_page(status, replies)
return JsonResponse(activity)
return JsonResponse(activitypub.get_replies(status, replies))
@login_required @login_required
@ -423,7 +402,7 @@ def book_page(request, book_id, tab='friends'):
''' info about a book ''' ''' info about a book '''
book = models.Book.objects.select_subclasses().get(id=book_id) book = models.Book.objects.select_subclasses().get(id=book_id)
if is_api_request(request): if is_api_request(request):
return JsonResponse(activitypub.get_book(book)) return JsonResponse(book.to_activity(), encoder=ActivityEncoder)
if isinstance(book, models.Work): if isinstance(book, models.Work):
book = book.default_edition book = book.default_edition
@ -531,7 +510,7 @@ def author_page(request, author_id):
return HttpResponseNotFound() return HttpResponseNotFound()
if is_api_request(request): if is_api_request(request):
return JsonResponse(activitypub.get_author(author)) return JsonResponse(author.to_activity(), encoder=ActivityEncoder)
books = models.Work.objects.filter(authors=author) books = models.Work.objects.filter(authors=author)
data = { data = {
@ -544,6 +523,13 @@ def author_page(request, author_id):
def tag_page(request, tag_id): def tag_page(request, tag_id):
''' books related to a tag ''' ''' books related to a tag '''
tag_obj = models.Tag.objects.filter(identifier=tag_id).first() tag_obj = models.Tag.objects.filter(identifier=tag_id).first()
if not tag_obj:
return HttpResponseNotFound()
if is_api_request(request):
return JsonResponse(
tag_obj.to_activity(**request.GET), encoder=ActivityEncoder)
books = models.Edition.objects.filter(tag__identifier=tag_id).distinct() books = models.Edition.objects.filter(tag__identifier=tag_id).distinct()
data = { data = {
'books': books, 'books': books,
@ -562,8 +548,7 @@ def shelf_page(request, username, shelf_identifier):
shelf = models.Shelf.objects.get(user=user, identifier=shelf_identifier) shelf = models.Shelf.objects.get(user=user, identifier=shelf_identifier)
if is_api_request(request): if is_api_request(request):
page = request.GET.get('page') return JsonResponse(shelf.to_activity(**request.GET))
return JsonResponse(activitypub.get_shelf(shelf, page=page))
data = { data = {
'shelf': shelf, 'shelf': shelf,