forked from mirrors/bookwyrm
Merge pull request #11 from mouse-reeve/stop-storing-ap-json
Stop storing ap json
This commit is contained in:
commit
978545717e
23 changed files with 630 additions and 476 deletions
7
fedireads/activitypub/__init__.py
Normal file
7
fedireads/activitypub/__init__.py
Normal file
|
@ -0,0 +1,7 @@
|
|||
''' bring activitypub functions into the namespace '''
|
||||
from .actor import get_actor
|
||||
from .collection import get_outbox, get_outbox_page, get_add, get_remove, \
|
||||
get_following, get_followers
|
||||
from .create import get_create
|
||||
from .follow import get_follow_request, get_accept
|
||||
from .status import get_review, get_review_article, get_status, get_replies
|
28
fedireads/activitypub/actor.py
Normal file
28
fedireads/activitypub/actor.py
Normal file
|
@ -0,0 +1,28 @@
|
|||
''' actor serializer '''
|
||||
|
||||
def get_actor(user):
|
||||
''' activitypub actor from db User '''
|
||||
return {
|
||||
'@context': [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
'https://w3id.org/security/v1'
|
||||
],
|
||||
|
||||
'id': user.actor,
|
||||
'type': 'Person',
|
||||
'preferredUsername': user.localname,
|
||||
'name': user.name,
|
||||
'inbox': user.inbox,
|
||||
'followers': '%s/followers' % user.actor,
|
||||
'following': '%s/following' % user.actor,
|
||||
'summary': user.summary,
|
||||
'publicKey': {
|
||||
'id': '%s/#main-key' % user.actor,
|
||||
'owner': user.actor,
|
||||
'publicKeyPem': user.public_key,
|
||||
},
|
||||
'endpoints': {
|
||||
'sharedInbox': user.shared_inbox,
|
||||
}
|
||||
}
|
||||
|
128
fedireads/activitypub/collection.py
Normal file
128
fedireads/activitypub/collection.py
Normal file
|
@ -0,0 +1,128 @@
|
|||
''' activitypub json for collections '''
|
||||
from uuid import uuid4
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from .status import get_status
|
||||
|
||||
def get_outbox(user, size):
|
||||
''' helper function for creating an outbox '''
|
||||
return get_ordered_collection(user.outbox, size)
|
||||
|
||||
|
||||
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:
|
||||
page['orderedItems'].append(get_status(status))
|
||||
|
||||
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
|
||||
|
||||
|
||||
def get_followers(user, page, follow_queryset):
|
||||
''' list of people who follow a user '''
|
||||
id_slug = '%s/followers' % user.actor
|
||||
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.actor
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
# TODO: generalize these pagination functions
|
||||
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.actor 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
|
||||
|
||||
|
||||
def get_ordered_collection(id_slug, size):
|
||||
''' create an ordered collection '''
|
||||
return {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
'id': id_slug,
|
||||
'type': 'OrderedCollection',
|
||||
'totalItems': size,
|
||||
'first': '%s?page=true' % id_slug,
|
||||
'last': '%s?min_id=0&page=true' % id_slug
|
||||
}
|
||||
|
||||
|
||||
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 an Add or Remove json blob '''
|
||||
uuid = uuid4()
|
||||
return {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
'id': str(uuid),
|
||||
'type': action,
|
||||
'actor': user.actor,
|
||||
'object': {
|
||||
'type': 'Document',
|
||||
'name': book.data['title'],
|
||||
'url': book.openlibrary_key
|
||||
},
|
||||
'target': {
|
||||
'type': 'Collection',
|
||||
'name': shelf.name,
|
||||
'id': shelf.absolute_id,
|
||||
}
|
||||
}
|
||||
|
||||
|
33
fedireads/activitypub/create.py
Normal file
33
fedireads/activitypub/create.py
Normal file
|
@ -0,0 +1,33 @@
|
|||
''' 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.actor,
|
||||
'published': status_json['published'],
|
||||
|
||||
'to': ['%s/followers' % user.actor],
|
||||
'cc': ['https://www.w3.org/ns/activitystreams#Public'],
|
||||
|
||||
'object': status_json,
|
||||
'signature': {
|
||||
'type': 'RsaSignature2017',
|
||||
'creator': '%s#main-key' % user.absolute_id,
|
||||
'created': status_json['published'],
|
||||
'signatureValue': b64encode(signed_message).decode('utf8'),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
29
fedireads/activitypub/follow.py
Normal file
29
fedireads/activitypub/follow.py
Normal file
|
@ -0,0 +1,29 @@
|
|||
''' 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.actor,
|
||||
'object': to_follow.actor,
|
||||
}
|
||||
|
||||
|
||||
def get_accept(user, request_activity):
|
||||
''' accept a follow request '''
|
||||
return {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
'id': '%s#accepts/follows/' % user.absolute_id,
|
||||
'type': 'Accept',
|
||||
'actor': user.actor,
|
||||
'object': request_activity,
|
||||
}
|
||||
|
73
fedireads/activitypub/status.py
Normal file
73
fedireads/activitypub/status.py
Normal file
|
@ -0,0 +1,73 @@
|
|||
''' status serializers '''
|
||||
def get_review(review):
|
||||
''' fedireads json for book reviews '''
|
||||
status = get_status(review)
|
||||
status['inReplyToBook'] = review.book.absolute_id
|
||||
status['fedireadsType'] = review.status_type,
|
||||
status['name'] = review.name
|
||||
status['rating'] = review.rating
|
||||
return status
|
||||
|
||||
|
||||
def get_review_article(review):
|
||||
''' a book review formatted for a non-fedireads isntance (mastodon) '''
|
||||
status = get_status(review)
|
||||
name = 'Review of "%s" (%d stars): %s' % (
|
||||
review.book.data['title'],
|
||||
review.rating,
|
||||
review.name
|
||||
)
|
||||
status['name'] = name
|
||||
return status
|
||||
|
||||
|
||||
def get_status(status):
|
||||
''' create activitypub json for a status '''
|
||||
user = status.user
|
||||
uri = status.absolute_id
|
||||
reply_parent_id = status.reply_parent.absolute_id \
|
||||
if status.reply_parent else None
|
||||
status_json = {
|
||||
'id': uri,
|
||||
'url': uri,
|
||||
'inReplyTo': reply_parent_id,
|
||||
'published': status.created_date.isoformat(),
|
||||
'attributedTo': user.actor,
|
||||
# TODO: assuming all posts are public -- should check privacy db field
|
||||
'to': ['https://www.w3.org/ns/activitystreams#Public'],
|
||||
'cc': ['%s/followers' % user.absolute_id],
|
||||
'sensitive': status.sensitive,
|
||||
'content': status.content,
|
||||
'type': status.activity_type,
|
||||
'attachment': [], # TODO: the book cover
|
||||
'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.absolute_id + '/replies'
|
||||
# TODO only partially implemented
|
||||
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]
|
||||
}
|
||||
}
|
|
@ -9,7 +9,7 @@ import requests
|
|||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def get_recipients(user, post_privacy, direct_recipients=None):
|
||||
def get_recipients(user, post_privacy, direct_recipients=None, limit=False):
|
||||
''' deduplicated list of recipient inboxes '''
|
||||
recipients = direct_recipients or []
|
||||
if post_privacy == 'direct':
|
||||
|
@ -17,7 +17,12 @@ def get_recipients(user, post_privacy, direct_recipients=None):
|
|||
return [u.inbox for u in recipients]
|
||||
|
||||
# load all the followers of the user who is sending the message
|
||||
followers = user.followers.all()
|
||||
if not limit:
|
||||
followers = user.followers.all()
|
||||
else:
|
||||
fedireads_user = limit == 'fedireads'
|
||||
followers = user.followers.filter(fedireads_user=fedireads_user).all()
|
||||
|
||||
if post_privacy == 'public':
|
||||
# post to public shared inboxes
|
||||
shared_inboxes = set(
|
||||
|
|
|
@ -40,8 +40,17 @@ class ReviewForm(ModelForm):
|
|||
}
|
||||
|
||||
|
||||
class CommentForm(ModelForm):
|
||||
class Meta:
|
||||
model = models.Status
|
||||
fields = ['content']
|
||||
help_texts = {f: None for f in fields}
|
||||
labels = {'content': 'Comment'}
|
||||
|
||||
|
||||
class EditUserForm(ModelForm):
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = ['avatar', 'name', 'summary']
|
||||
help_texts = {f: None for f in fields}
|
||||
|
||||
|
|
|
@ -9,9 +9,10 @@ from django.views.decorators.csrf import csrf_exempt
|
|||
import json
|
||||
import requests
|
||||
|
||||
from fedireads import activitypub
|
||||
from fedireads import models
|
||||
from fedireads import outgoing
|
||||
from fedireads.activity import create_review
|
||||
from fedireads.status import create_review, create_status
|
||||
from fedireads.remote_user import get_or_create_remote_user
|
||||
|
||||
|
||||
|
@ -33,10 +34,7 @@ def shared_inbox(request):
|
|||
return HttpResponse(status=401)
|
||||
|
||||
response = HttpResponseNotFound()
|
||||
if activity['type'] == 'Add':
|
||||
response = handle_incoming_shelve(activity)
|
||||
|
||||
elif activity['type'] == 'Follow':
|
||||
if activity['type'] == 'Follow':
|
||||
response = handle_incoming_follow(activity)
|
||||
|
||||
elif activity['type'] == 'Create':
|
||||
|
@ -45,7 +43,7 @@ def shared_inbox(request):
|
|||
elif activity['type'] == 'Accept':
|
||||
response = handle_incoming_follow_accept(activity)
|
||||
|
||||
# TODO: Undo, Remove, etc
|
||||
# TODO: Add, Undo, Remove, etc
|
||||
|
||||
return response
|
||||
|
||||
|
@ -114,29 +112,7 @@ def get_actor(request, username):
|
|||
return HttpResponseBadRequest()
|
||||
|
||||
user = models.User.objects.get(localname=username)
|
||||
return JsonResponse({
|
||||
'@context': [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
'https://w3id.org/security/v1'
|
||||
],
|
||||
|
||||
'id': user.actor,
|
||||
'type': 'Person',
|
||||
'preferredUsername': user.localname,
|
||||
'name': user.name,
|
||||
'inbox': user.inbox,
|
||||
'followers': '%s/followers' % user.actor,
|
||||
'following': '%s/following' % user.actor,
|
||||
'summary': user.summary,
|
||||
'publicKey': {
|
||||
'id': '%s/#main-key' % user.actor,
|
||||
'owner': user.actor,
|
||||
'publicKeyPem': user.public_key,
|
||||
},
|
||||
'endpoints': {
|
||||
'sharedInbox': user.shared_inbox,
|
||||
}
|
||||
})
|
||||
return JsonResponse(activitypub.get_actor(user))
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
|
@ -154,7 +130,26 @@ def get_status(request, username, status_id):
|
|||
if user != status.user:
|
||||
return HttpResponseNotFound()
|
||||
|
||||
return JsonResponse(status.activity)
|
||||
return JsonResponse(activitypub.get_status(status))
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
def get_replies(request, username, status_id):
|
||||
''' ordered collection of replies to a status '''
|
||||
# TODO: this isn't a full implmentation
|
||||
if request.method != 'GET':
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
status = models.Status.objects.get(id=status_id)
|
||||
if status.user.localname != username:
|
||||
return HttpResponseNotFound()
|
||||
|
||||
replies = models.Status.objects.filter(
|
||||
reply_parent=status
|
||||
).first()
|
||||
|
||||
replies_activity = activitypub.get_replies(status, [replies])
|
||||
return JsonResponse(replies_activity)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
|
@ -165,7 +160,8 @@ def get_followers(request, username):
|
|||
|
||||
user = models.User.objects.get(localname=username)
|
||||
followers = user.followers
|
||||
return format_follow_info(user, request.GET.get('page'), followers)
|
||||
page = request.GET.get('page')
|
||||
return JsonResponse(activitypub.get_followers(user, page, followers))
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
|
@ -176,70 +172,8 @@ def get_following(request, username):
|
|||
|
||||
user = models.User.objects.get(localname=username)
|
||||
following = models.User.objects.filter(followers=user)
|
||||
return format_follow_info(user, request.GET.get('page'), following)
|
||||
|
||||
|
||||
def format_follow_info(user, page, follow_queryset):
|
||||
''' create the activitypub json for followers/following '''
|
||||
id_slug = '%s/following' % user.actor
|
||||
if page:
|
||||
return JsonResponse(get_follow_page(follow_queryset, id_slug, page))
|
||||
count = follow_queryset.count()
|
||||
return JsonResponse({
|
||||
'@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.actor 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
|
||||
|
||||
|
||||
def handle_incoming_shelve(activity):
|
||||
''' receiving an Add activity (to shelve a book) '''
|
||||
# TODO what happens here? If it's a remote over, then I think
|
||||
# I should save both the activity and the ShelfBook entry. But
|
||||
# I'll do that later.
|
||||
uuid = activity['id']
|
||||
models.ShelveActivity.objects.get(uuid=uuid)
|
||||
'''
|
||||
book_id = activity['object']['url']
|
||||
book = openlibrary.get_or_create_book(book_id)
|
||||
user_ap_id = activity['actor'].replace('https//:', '')
|
||||
user = models.User.objects.get(actor=user_ap_id)
|
||||
if not user or not user.local:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
shelf = models.Shelf.objects.get(activitypub_id=activity['target']['id'])
|
||||
models.ShelfBook(
|
||||
shelf=shelf,
|
||||
book=book,
|
||||
added_by=user,
|
||||
).save()
|
||||
'''
|
||||
return HttpResponse()
|
||||
page = request.GET.get('page')
|
||||
return JsonResponse(activitypub.get_following(user, page, following))
|
||||
|
||||
|
||||
def handle_incoming_follow(activity):
|
||||
|
@ -248,12 +182,6 @@ def handle_incoming_follow(activity):
|
|||
to_follow = models.User.objects.get(actor=activity['object'])
|
||||
# figure out who they are
|
||||
user = get_or_create_remote_user(activity['actor'])
|
||||
models.FollowActivity(
|
||||
uuid=activity['id'],
|
||||
user=user,
|
||||
followed=to_follow,
|
||||
content=activity,
|
||||
)
|
||||
# TODO: allow users to manually approve requests
|
||||
outgoing.handle_outgoing_accept(user, to_follow, activity)
|
||||
return HttpResponse()
|
||||
|
@ -277,37 +205,30 @@ def handle_incoming_create(activity):
|
|||
if not 'object' in activity:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
# TODO: should only create notes if they are relevent to a book,
|
||||
# so, not every single thing someone posts on mastodon
|
||||
response = HttpResponse()
|
||||
content = activity['object'].get('content')
|
||||
if activity['object'].get('fedireadsType') == 'Review' and \
|
||||
'inReplyTo' in activity['object']:
|
||||
book = activity['object']['inReplyTo']
|
||||
'inReplyToBook' in activity['object']:
|
||||
book = activity['object']['inReplyToBook']
|
||||
book = book.split('/')[-1]
|
||||
name = activity['object'].get('name')
|
||||
content = activity['object'].get('content')
|
||||
rating = activity['object'].get('rating')
|
||||
if user.local:
|
||||
review_id = activity['object']['id'].split('/')[-1]
|
||||
review = models.Review.objects.get(id=review_id)
|
||||
models.Review.objects.get(id=review_id)
|
||||
else:
|
||||
try:
|
||||
review = create_review(user, book, name, content, rating)
|
||||
create_review(user, book, name, content, rating)
|
||||
except ValueError:
|
||||
return HttpResponseBadRequest()
|
||||
models.ReviewActivity.objects.create(
|
||||
uuid=activity['id'],
|
||||
user=user,
|
||||
content=activity['object'],
|
||||
activity_type=activity['object']['type'],
|
||||
book=review.book,
|
||||
)
|
||||
elif not user.local:
|
||||
try:
|
||||
create_status(user, content)
|
||||
except ValueError:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
else:
|
||||
models.Activity.objects.create(
|
||||
uuid=activity['id'],
|
||||
user=user,
|
||||
content=activity,
|
||||
activity_type=activity['object']['type']
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
|
@ -321,12 +242,5 @@ def handle_incoming_accept(activity):
|
|||
# save this relationship in the db
|
||||
followed.followers.add(user)
|
||||
|
||||
# save the activity record
|
||||
models.FollowActivity(
|
||||
uuid=activity['id'],
|
||||
user=user,
|
||||
followed=followed,
|
||||
content=activity,
|
||||
).save()
|
||||
return HttpResponse()
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# Generated by Django 3.0.3 on 2020-02-15 22:50
|
||||
# Generated by Django 3.0.3 on 2020-02-17 02:39
|
||||
|
||||
from django.conf import settings
|
||||
import django.contrib.auth.models
|
||||
|
@ -56,20 +56,6 @@ class Migration(migrations.Migration):
|
|||
('objects', django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Activity',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('uuid', models.CharField(max_length=255, unique=True)),
|
||||
('content', fedireads.utils.fields.JSONField(max_length=5000)),
|
||||
('activity_type', models.CharField(max_length=255)),
|
||||
('fedireads_type', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('local', models.BooleanField(default=True)),
|
||||
('created_date', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_date', models.DateTimeField(auto_now=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Author',
|
||||
fields=[
|
||||
|
@ -118,11 +104,14 @@ class Migration(migrations.Migration):
|
|||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('status_type', models.CharField(default='Note', max_length=255)),
|
||||
('activity', fedireads.utils.fields.JSONField(max_length=5000, null=True)),
|
||||
('activity_type', models.CharField(default='Note', max_length=255)),
|
||||
('local', models.BooleanField(default=True)),
|
||||
('privacy', models.CharField(default='public', max_length=255)),
|
||||
('sensitive', models.BooleanField(default=False)),
|
||||
('content', models.TextField(blank=True, null=True)),
|
||||
('created_date', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_date', models.DateTimeField(auto_now=True)),
|
||||
('mention_books', models.ManyToManyField(related_name='mention_book', to='fedireads.Book')),
|
||||
('mention_users', models.ManyToManyField(related_name='mention_user', to=settings.AUTH_USER_MODEL)),
|
||||
('reply_parent', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='fedireads.Status')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
|
@ -175,27 +164,10 @@ class Migration(migrations.Migration):
|
|||
name='user_permissions',
|
||||
field=models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ShelveActivity',
|
||||
fields=[
|
||||
('activity_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='fedireads.Activity')),
|
||||
('book', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fedireads.Book')),
|
||||
('shelf', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fedireads.Shelf')),
|
||||
],
|
||||
bases=('fedireads.activity',),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='shelf',
|
||||
unique_together={('user', 'identifier')},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ReviewActivity',
|
||||
fields=[
|
||||
('activity_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='fedireads.Activity')),
|
||||
('book', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fedireads.Book')),
|
||||
],
|
||||
bases=('fedireads.activity',),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Review',
|
||||
fields=[
|
||||
|
@ -206,12 +178,4 @@ class Migration(migrations.Migration):
|
|||
],
|
||||
bases=('fedireads.status',),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='FollowActivity',
|
||||
fields=[
|
||||
('activity_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='fedireads.Activity')),
|
||||
('followed', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='followed', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
bases=('fedireads.activity',),
|
||||
),
|
||||
]
|
||||
|
|
80
fedireads/migrations/0002_auto_20200219_0118.py
Normal file
80
fedireads/migrations/0002_auto_20200219_0118.py
Normal file
|
@ -0,0 +1,80 @@
|
|||
# Generated by Django 3.0.3 on 2020-02-19 01:18
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fedireads', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='author',
|
||||
old_name='added_date',
|
||||
new_name='created_date',
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name='book',
|
||||
old_name='added_date',
|
||||
new_name='created_date',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='author',
|
||||
name='updated_date',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='book',
|
||||
name='updated_date',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='shelf',
|
||||
name='updated_date',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='user',
|
||||
name='created_date',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='user',
|
||||
name='updated_date',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='author',
|
||||
name='content',
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='book',
|
||||
name='content',
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='federatedserver',
|
||||
name='content',
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='federatedserver',
|
||||
name='created_date',
|
||||
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='shelf',
|
||||
name='content',
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='shelfbook',
|
||||
name='content',
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='fedireads_user',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
]
|
|
@ -1,6 +1,5 @@
|
|||
''' bring all the models into the app namespace '''
|
||||
from .book import Shelf, ShelfBook, Book, Author
|
||||
from .user import User, FederatedServer
|
||||
from .activity import Activity, ShelveActivity, FollowActivity, \
|
||||
ReviewActivity, Status, Review
|
||||
from .activity import Status, Review
|
||||
|
||||
|
|
|
@ -3,81 +3,31 @@ from django.core.validators import MaxValueValidator, MinValueValidator
|
|||
from django.db import models
|
||||
from model_utils.managers import InheritanceManager
|
||||
|
||||
from fedireads.utils.fields import JSONField
|
||||
|
||||
# TODO: I don't know that these Activity models should exist, at least in this way
|
||||
# but I'm not sure what the right approach is for now.
|
||||
|
||||
class Activity(models.Model):
|
||||
''' basic fields for storing activities '''
|
||||
uuid = models.CharField(max_length=255, unique=True)
|
||||
user = models.ForeignKey('User', on_delete=models.PROTECT)
|
||||
content = JSONField(max_length=5000)
|
||||
# the activitypub activity type (Create, Add, Follow, ...)
|
||||
activity_type = models.CharField(max_length=255)
|
||||
# custom types internal to fedireads (Review, Shelve, ...)
|
||||
fedireads_type = models.CharField(max_length=255, blank=True, null=True)
|
||||
local = models.BooleanField(default=True)
|
||||
created_date = models.DateTimeField(auto_now_add=True)
|
||||
updated_date = models.DateTimeField(auto_now=True)
|
||||
objects = InheritanceManager()
|
||||
from fedireads.utils.models import FedireadsModel
|
||||
|
||||
|
||||
class ShelveActivity(Activity):
|
||||
''' someone put a book on a shelf '''
|
||||
book = models.ForeignKey('Book', on_delete=models.PROTECT)
|
||||
shelf = models.ForeignKey('Shelf', on_delete=models.PROTECT)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.activity_type:
|
||||
self.activity_type = 'Add'
|
||||
self.fedireads_type = 'Shelve'
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class FollowActivity(Activity):
|
||||
''' record follow requests sent out '''
|
||||
followed = models.ForeignKey(
|
||||
'User',
|
||||
related_name='followed',
|
||||
on_delete=models.PROTECT
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.activity_type = 'Follow'
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class ReviewActivity(Activity):
|
||||
book = models.ForeignKey('Book', on_delete=models.PROTECT)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.activity_type = 'Note'
|
||||
self.fedireads_type = 'Review'
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class Status(models.Model):
|
||||
''' reply to a review, etc '''
|
||||
class Status(FedireadsModel):
|
||||
''' any post, like a reply to a review, etc '''
|
||||
user = models.ForeignKey('User', on_delete=models.PROTECT)
|
||||
status_type = models.CharField(max_length=255, default='Note')
|
||||
activity = JSONField(max_length=5000, null=True)
|
||||
mention_users = models.ManyToManyField('User', related_name='mention_user')
|
||||
mention_books = models.ManyToManyField('Book', related_name='mention_book')
|
||||
activity_type = models.CharField(max_length=255, default='Note')
|
||||
local = models.BooleanField(default=True)
|
||||
privacy = models.CharField(max_length=255, default='public')
|
||||
sensitive = models.BooleanField(default=False)
|
||||
reply_parent = models.ForeignKey(
|
||||
'self',
|
||||
null=True,
|
||||
on_delete=models.PROTECT
|
||||
)
|
||||
content = models.TextField(blank=True, null=True)
|
||||
created_date = models.DateTimeField(auto_now_add=True)
|
||||
updated_date = models.DateTimeField(auto_now=True)
|
||||
objects = InheritanceManager()
|
||||
|
||||
|
||||
class Review(Status):
|
||||
''' a book review '''
|
||||
book = models.ForeignKey('Book', on_delete=models.PROTECT)
|
||||
name = models.CharField(max_length=255)
|
||||
book = models.ForeignKey('Book', on_delete=models.PROTECT)
|
||||
rating = models.IntegerField(
|
||||
default=0,
|
||||
validators=[MinValueValidator(0), MaxValueValidator(5)]
|
||||
|
@ -85,6 +35,6 @@ class Review(Status):
|
|||
|
||||
def save(self, *args, **kwargs):
|
||||
self.status_type = 'Review'
|
||||
self.activity_type = 'Article'
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
|
|
|
@ -1,9 +1,12 @@
|
|||
''' database schema for books and shelves '''
|
||||
from django.db import models
|
||||
|
||||
from fedireads.settings import DOMAIN
|
||||
from fedireads.utils.fields import JSONField
|
||||
from fedireads.utils.models import FedireadsModel
|
||||
|
||||
|
||||
class Shelf(models.Model):
|
||||
class Shelf(FedireadsModel):
|
||||
name = models.CharField(max_length=100)
|
||||
identifier = models.CharField(max_length=100)
|
||||
user = models.ForeignKey('User', on_delete=models.PROTECT)
|
||||
|
@ -14,14 +17,19 @@ class Shelf(models.Model):
|
|||
through='ShelfBook',
|
||||
through_fields=('shelf', 'book')
|
||||
)
|
||||
created_date = models.DateTimeField(auto_now_add=True)
|
||||
updated_date = models.DateTimeField(auto_now=True)
|
||||
|
||||
@property
|
||||
def absolute_id(self):
|
||||
''' use shelf identifier as absolute id '''
|
||||
base_path = self.user.absolute_id
|
||||
model_name = type(self).__name__.lower()
|
||||
return '%s/%s/%s' % (base_path, model_name, self.identifier)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('user', 'identifier')
|
||||
|
||||
|
||||
class ShelfBook(models.Model):
|
||||
class ShelfBook(FedireadsModel):
|
||||
# many to many join table for books and shelves
|
||||
book = models.ForeignKey('Book', on_delete=models.PROTECT)
|
||||
shelf = models.ForeignKey('Shelf', on_delete=models.PROTECT)
|
||||
|
@ -31,13 +39,12 @@ class ShelfBook(models.Model):
|
|||
null=True,
|
||||
on_delete=models.PROTECT
|
||||
)
|
||||
created_date = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('book', 'shelf')
|
||||
|
||||
|
||||
class Book(models.Model):
|
||||
class Book(FedireadsModel):
|
||||
''' a non-canonical copy of a work (not book) from open library '''
|
||||
openlibrary_key = models.CharField(max_length=255, unique=True)
|
||||
data = JSONField()
|
||||
|
@ -56,14 +63,17 @@ class Book(models.Model):
|
|||
null=True,
|
||||
on_delete=models.PROTECT
|
||||
)
|
||||
added_date = models.DateTimeField(auto_now_add=True)
|
||||
updated_date = models.DateTimeField(auto_now=True)
|
||||
|
||||
@property
|
||||
def absolute_id(self):
|
||||
''' constructs the absolute reference to any db object '''
|
||||
base_path = 'https://%s' % DOMAIN
|
||||
model_name = type(self).__name__.lower()
|
||||
return '%s/%s/%s' % (base_path, model_name, self.openlibrary_key)
|
||||
|
||||
|
||||
class Author(models.Model):
|
||||
class Author(FedireadsModel):
|
||||
''' copy of an author from OL '''
|
||||
openlibrary_key = models.CharField(max_length=255)
|
||||
data = JSONField()
|
||||
added_date = models.DateTimeField(auto_now_add=True)
|
||||
updated_date = models.DateTimeField(auto_now=True)
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@ from django.dispatch import receiver
|
|||
|
||||
from fedireads.models import Shelf
|
||||
from fedireads.settings import DOMAIN
|
||||
from fedireads.utils.models import FedireadsModel
|
||||
|
||||
|
||||
class User(AbstractUser):
|
||||
|
@ -24,6 +25,7 @@ class User(AbstractUser):
|
|||
outbox = models.CharField(max_length=255, unique=True)
|
||||
summary = models.TextField(blank=True, null=True)
|
||||
local = models.BooleanField(default=True)
|
||||
fedireads_user = models.BooleanField(default=True)
|
||||
localname = models.CharField(
|
||||
max_length=255,
|
||||
null=True,
|
||||
|
@ -33,11 +35,15 @@ class User(AbstractUser):
|
|||
name = models.CharField(max_length=100, blank=True, null=True)
|
||||
avatar = models.ImageField(upload_to='avatars/', blank=True, null=True)
|
||||
followers = models.ManyToManyField('self', symmetrical=False)
|
||||
created_date = models.DateTimeField(auto_now_add=True)
|
||||
updated_date = models.DateTimeField(auto_now=True)
|
||||
|
||||
@property
|
||||
def absolute_id(self):
|
||||
''' users are identified by their username, so overriding this prop '''
|
||||
model_name = type(self).__name__.lower()
|
||||
return 'https://%s/%s/%s' % (DOMAIN, model_name, self.localname)
|
||||
|
||||
|
||||
class FederatedServer(models.Model):
|
||||
class FederatedServer(FedireadsModel):
|
||||
''' store which server's we federate with '''
|
||||
server_name = models.CharField(max_length=255, unique=True)
|
||||
# federated, blocked, whatever else
|
||||
|
@ -56,10 +62,10 @@ def execute_before_save(sender, instance, *args, **kwargs):
|
|||
# populate fields for local users
|
||||
instance.localname = instance.username
|
||||
instance.username = '%s@%s' % (instance.username, DOMAIN)
|
||||
instance.actor = 'https://%s/user/%s' % (DOMAIN, instance.localname)
|
||||
instance.inbox = 'https://%s/user/%s/inbox' % (DOMAIN, instance.localname)
|
||||
instance.actor = instance.absolute_id
|
||||
instance.inbox = '%s/inbox' % instance.absolute_id
|
||||
instance.shared_inbox = 'https://%s/inbox' % DOMAIN
|
||||
instance.outbox = 'https://%s/user/%s/outbox' % (DOMAIN, instance.localname)
|
||||
instance.outbox = '%s/outbox' % instance.absolute_id
|
||||
if not instance.private_key:
|
||||
random_generator = Random.new().read
|
||||
key = RSA.generate(1024, random_generator)
|
||||
|
|
|
@ -1,20 +1,14 @@
|
|||
''' handles all the activity coming out of the server '''
|
||||
from base64 import b64encode
|
||||
from Crypto.PublicKey import RSA
|
||||
from Crypto.Signature import pkcs1_15
|
||||
from Crypto.Hash import SHA256
|
||||
from datetime import datetime
|
||||
from django.http import HttpResponseNotFound, JsonResponse
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
import requests
|
||||
from urllib.parse import urlencode
|
||||
from uuid import uuid4
|
||||
|
||||
from fedireads import activitypub
|
||||
from fedireads import models
|
||||
from fedireads.activity import create_review
|
||||
from fedireads.status import create_review, create_status
|
||||
from fedireads.remote_user import get_or_create_remote_user
|
||||
from fedireads.broadcast import get_recipients, broadcast
|
||||
from fedireads.settings import DOMAIN
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
|
@ -30,7 +24,6 @@ def outbox(request, username):
|
|||
min_id = request.GET.get('min_id')
|
||||
max_id = request.GET.get('max_id')
|
||||
|
||||
query_path = user.outbox + '?'
|
||||
# filters for use in the django queryset min/max
|
||||
filters = {}
|
||||
# params for the outbox page id
|
||||
|
@ -41,39 +34,20 @@ def outbox(request, username):
|
|||
if max_id != None:
|
||||
params['max_id'] = max_id
|
||||
filters['id__lte'] = max_id
|
||||
collection_id = query_path + urlencode(params)
|
||||
|
||||
messages = models.Activity.objects.filter(
|
||||
page_id = user.outbox + '?' + urlencode(params)
|
||||
statuses = models.Status.objects.filter(
|
||||
user=user,
|
||||
activity_type__in=['Article', 'Note'],
|
||||
**filters
|
||||
).all()[:limit]
|
||||
).all()[:limit]
|
||||
|
||||
outbox_page = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
'id': collection_id,
|
||||
'type': 'OrderedCollectionPage',
|
||||
'partOf': user.outbox,
|
||||
'orderedItems': [m.content for m in messages],
|
||||
}
|
||||
if max_id:
|
||||
outbox_page['next'] = query_path + \
|
||||
urlencode({'min_id': max_id, 'page': 'true'})
|
||||
if min_id:
|
||||
outbox_page['prev'] = query_path + \
|
||||
urlencode({'max_id': min_id, 'page': 'true'})
|
||||
return JsonResponse(outbox_page)
|
||||
return JsonResponse(
|
||||
activitypub.get_outbox_page(user, page_id, statuses, max_id, min_id)
|
||||
)
|
||||
|
||||
# collection overview
|
||||
size = models.Review.objects.filter(user=user).count()
|
||||
return JsonResponse({
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
'id': '%s/outbox' % user.actor,
|
||||
'type': 'OrderedCollection',
|
||||
'totalItems': size,
|
||||
'first': '%s/outbox?page=true' % user.actor,
|
||||
'last': '%s/outbox?min_id=0&page=true' % user.actor
|
||||
})
|
||||
size = models.Status.objects.filter(user=user).count()
|
||||
return JsonResponse(activitypub.get_outbox(user, size))
|
||||
|
||||
|
||||
def handle_account_search(query):
|
||||
|
@ -97,127 +71,56 @@ def handle_account_search(query):
|
|||
|
||||
def handle_outgoing_follow(user, to_follow):
|
||||
''' someone local wants to follow someone '''
|
||||
uuid = uuid4()
|
||||
activity = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
'id': 'https://%s/%s' % (DOMAIN, str(uuid)),
|
||||
'summary': '',
|
||||
'type': 'Follow',
|
||||
'actor': user.actor,
|
||||
'object': to_follow.actor,
|
||||
}
|
||||
|
||||
activity = activitypub.get_follow_request(user, to_follow)
|
||||
errors = broadcast(user, activity, [to_follow.inbox])
|
||||
for error in errors:
|
||||
# TODO: following masto users is returning 400
|
||||
raise(error['error'])
|
||||
|
||||
|
||||
def handle_outgoing_accept(user, to_follow, activity):
|
||||
def handle_outgoing_accept(user, to_follow, request_activity):
|
||||
''' send an acceptance message to a follow request '''
|
||||
to_follow.followers.add(user)
|
||||
activity = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
'id': 'https://%s/%s#accepts/follows/' % (DOMAIN, to_follow.localname),
|
||||
'type': 'Accept',
|
||||
'actor': to_follow.actor,
|
||||
'object': activity,
|
||||
}
|
||||
recipient = get_recipients(
|
||||
to_follow,
|
||||
'direct',
|
||||
direct_recipients=[user]
|
||||
)
|
||||
activity = activitypub.get_accept(to_follow, request_activity)
|
||||
recipient = get_recipients(to_follow, 'direct', direct_recipients=[user])
|
||||
broadcast(to_follow, activity, recipient)
|
||||
|
||||
|
||||
def handle_shelve(user, book, shelf):
|
||||
''' a local user is getting a book put on their shelf '''
|
||||
# update the database
|
||||
# TODO: this should probably happen in incoming instead
|
||||
models.ShelfBook(book=book, shelf=shelf, added_by=user).save()
|
||||
|
||||
# send out the activitypub action
|
||||
summary = '%s marked %s as %s' % (
|
||||
user.username,
|
||||
book.data['title'],
|
||||
shelf.name
|
||||
)
|
||||
|
||||
uuid = uuid4()
|
||||
activity = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
'id': str(uuid),
|
||||
'summary': summary,
|
||||
'type': 'Add',
|
||||
'actor': user.actor,
|
||||
'object': {
|
||||
'type': 'Document',
|
||||
'name': book.data['title'],
|
||||
'url': book.openlibrary_key
|
||||
},
|
||||
'target': {
|
||||
'type': 'Collection',
|
||||
'name': shelf.name,
|
||||
'id': 'https://%s/user/%s/shelf/%s' % \
|
||||
(DOMAIN, user.localname, shelf.identifier)
|
||||
}
|
||||
}
|
||||
activity = activitypub.get_add(user, book, shelf)
|
||||
recipients = get_recipients(user, 'public')
|
||||
|
||||
models.ShelveActivity(
|
||||
uuid=uuid,
|
||||
user=user,
|
||||
content=activity,
|
||||
shelf=shelf,
|
||||
book=book,
|
||||
).save()
|
||||
|
||||
broadcast(user, activity, recipients)
|
||||
|
||||
# tell the world about this cool thing that happened
|
||||
verb = {
|
||||
'to-read': 'wants to read',
|
||||
'reading': 'started reading',
|
||||
'read': 'finished reading'
|
||||
}[shelf.identifier]
|
||||
name = user.name if user.name else user.localname
|
||||
message = '%s %s %s' % (name, verb, book.data['title'])
|
||||
status = create_status(user, message, mention_books=[book])
|
||||
|
||||
activity = activitypub.get_status(status)
|
||||
create_activity = activitypub.get_create(user, activity)
|
||||
|
||||
broadcast(user, create_activity, recipients)
|
||||
|
||||
|
||||
def handle_unshelve(user, book, shelf):
|
||||
''' a local user is getting a book put on their shelf '''
|
||||
# update the database
|
||||
# TODO: this should probably happen in incoming instead
|
||||
row = models.ShelfBook.objects.get(book=book, shelf=shelf)
|
||||
row.delete()
|
||||
|
||||
# send out the activitypub action
|
||||
summary = '%s removed %s from %s' % (
|
||||
user.username,
|
||||
book.data['title'],
|
||||
shelf.name
|
||||
)
|
||||
|
||||
uuid = uuid4()
|
||||
activity = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
'id': str(uuid),
|
||||
'summary': summary,
|
||||
'type': 'Remove',
|
||||
'actor': user.actor,
|
||||
'object': {
|
||||
'type': 'Document',
|
||||
'name': book.data['title'],
|
||||
'url': book.openlibrary_key
|
||||
},
|
||||
'target': {
|
||||
'type': 'Collection',
|
||||
'name': shelf.name,
|
||||
'id': 'https://%s/user/%s/shelf/%s' % \
|
||||
(DOMAIN, user.localname, shelf.identifier)
|
||||
}
|
||||
}
|
||||
activity = activitypub.get_remove(user, book, shelf)
|
||||
recipients = get_recipients(user, 'public')
|
||||
|
||||
models.ShelveActivity(
|
||||
uuid=uuid,
|
||||
user=user,
|
||||
content=activity,
|
||||
shelf=shelf,
|
||||
book=book,
|
||||
activity_type='Remove',
|
||||
).save()
|
||||
|
||||
broadcast(user, activity, recipients)
|
||||
|
||||
|
||||
|
@ -226,63 +129,25 @@ def handle_review(user, book, name, content, rating):
|
|||
# validated and saves the review in the database so it has an id
|
||||
review = create_review(user, book, name, content, rating)
|
||||
|
||||
review_path = 'https://%s/user/%s/status/%d' % \
|
||||
(DOMAIN, user.localname, review.id)
|
||||
book_path = 'https://%s/book/%s' % (DOMAIN, review.book.openlibrary_key)
|
||||
review_activity = activitypub.get_review(review)
|
||||
review_create_activity = activitypub.get_create(user, review_activity)
|
||||
fr_recipients = get_recipients(user, 'public', limit='fedireads')
|
||||
broadcast(user, review_create_activity, fr_recipients)
|
||||
|
||||
now = datetime.utcnow().isoformat() #TODO: should this be http_date?
|
||||
review_activity = {
|
||||
'id': review_path,
|
||||
'url': review_path,
|
||||
'inReplyTo': book_path,
|
||||
'published': now,
|
||||
'attributedTo': user.actor,
|
||||
# TODO: again, assuming all posts are public
|
||||
'to': ['https://www.w3.org/ns/activitystreams#Public'],
|
||||
'cc': ['https://%s/user/%s/followers' % (DOMAIN, user.localname)],
|
||||
'sensitive': False, # TODO: allow content warning/sensitivity
|
||||
'content': content,
|
||||
'type': 'Note',
|
||||
'fedireadsType': 'Review',
|
||||
'name': name,
|
||||
'rating': rating, # fedireads-only custom field
|
||||
'attachment': [], # TODO: the book cover
|
||||
'replies': {
|
||||
'id': '%s/replies' % review_path,
|
||||
'type': 'Collection',
|
||||
'first': {
|
||||
'type': 'CollectionPage',
|
||||
'next': '%s/replies?only_other_accounts=true&page=true' % \
|
||||
review_path,
|
||||
'partOf': '%s/replies' % review_path,
|
||||
'items': [], # TODO: populate with replies
|
||||
}
|
||||
}
|
||||
}
|
||||
review.activity = review_activity
|
||||
review.save()
|
||||
# re-format the activity for non-fedireads servers
|
||||
article_activity = activitypub.get_review_article(review)
|
||||
article_create_activity = activitypub.get_create(user, article_activity)
|
||||
|
||||
signer = pkcs1_15.new(RSA.import_key(user.private_key))
|
||||
signed_message = signer.sign(SHA256.new(content.encode('utf8')))
|
||||
create_activity = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
other_recipients = get_recipients(user, 'public', limit='other')
|
||||
broadcast(user, article_create_activity, other_recipients)
|
||||
|
||||
'id': '%s/activity' % review_path,
|
||||
'type': 'Create',
|
||||
'actor': user.actor,
|
||||
'published': now,
|
||||
|
||||
'to': ['%s/followers' % user.actor],
|
||||
'cc': ['https://www.w3.org/ns/activitystreams#Public'],
|
||||
|
||||
'object': review_activity,
|
||||
'signature': {
|
||||
'type': 'RsaSignature2017',
|
||||
'creator': 'https://%s/user/%s#main-key' % (DOMAIN, user.localname),
|
||||
'created': now,
|
||||
'signatureValue': b64encode(signed_message).decode('utf8'),
|
||||
}
|
||||
}
|
||||
def handle_comment(user, review, content):
|
||||
''' post a review '''
|
||||
# validated and saves the comment in the database so it has an id
|
||||
comment = create_status(user, content, reply_parent=review)
|
||||
comment_activity = activitypub.get_status(comment)
|
||||
create_activity = activitypub.get_create(user, comment_activity)
|
||||
|
||||
recipients = get_recipients(user, 'public')
|
||||
broadcast(user, create_activity, recipients)
|
||||
|
|
|
@ -41,7 +41,8 @@ def get_or_create_remote_user(actor):
|
|||
shared_inbox=shared_inbox,
|
||||
# TODO: I'm never actually using this for remote users
|
||||
public_key=data.get('publicKey').get('publicKeyPem'),
|
||||
local=False
|
||||
local=False,
|
||||
fedireads_user=False,
|
||||
)
|
||||
except KeyError:
|
||||
return False
|
||||
|
|
|
@ -9,15 +9,12 @@ def create_review(user, possible_book, name, content, rating):
|
|||
# throws a value error if the book is not found
|
||||
book = get_or_create_book(possible_book)
|
||||
|
||||
# sanitize review html
|
||||
parser = InputHtmlParser()
|
||||
parser.feed(content)
|
||||
content = parser.get_output()
|
||||
content = sanitize(content)
|
||||
|
||||
# no ratings outside of 0-5
|
||||
rating = rating if 0 <= rating <= 5 else 0
|
||||
|
||||
review = models.Review.objects.create(
|
||||
return models.Review.objects.create(
|
||||
user=user,
|
||||
book=book,
|
||||
name=name,
|
||||
|
@ -25,6 +22,31 @@ def create_review(user, possible_book, name, content, rating):
|
|||
content=content,
|
||||
)
|
||||
|
||||
return review
|
||||
|
||||
def create_status(user, content, reply_parent=None, mention_books=None):
|
||||
''' a status update '''
|
||||
# TODO: handle @'ing users
|
||||
|
||||
# sanitize input html
|
||||
parser = InputHtmlParser()
|
||||
parser.feed(content)
|
||||
content = parser.get_output()
|
||||
|
||||
status = models.Status.objects.create(
|
||||
user=user,
|
||||
content=content,
|
||||
reply_parent=reply_parent,
|
||||
)
|
||||
|
||||
if mention_books:
|
||||
for book in mention_books:
|
||||
status.mention_books.add(book)
|
||||
|
||||
return status
|
||||
|
||||
|
||||
def sanitize(content):
|
||||
''' remove invalid html from free text '''
|
||||
parser = InputHtmlParser()
|
||||
parser.feed(content)
|
||||
return parser.get_output()
|
|
@ -55,40 +55,34 @@
|
|||
<h2>
|
||||
{% include 'snippets/avatar.html' with user=activity.user %}
|
||||
{% include 'snippets/username.html' with user=activity.user %}
|
||||
{% if activity.fedireads_type == 'Shelve' %}
|
||||
{# display a reading/shelving activity #}
|
||||
{% if activity.shelf.identifier == 'to-read' %}
|
||||
wants to read
|
||||
{% elif activity.shelf.identifier == 'read' %}
|
||||
finished reading
|
||||
{% elif activity.shelf.identifier == 'reading' %}
|
||||
started reading
|
||||
{% else %}
|
||||
shelved in "{{ activity.shelf.name }}"
|
||||
{% endif %}
|
||||
</h2>
|
||||
<div class="book-preview">
|
||||
{% include 'snippets/book.html' with book=activity.book size=large description=True %}
|
||||
</div>
|
||||
<div class="interaction"><button>⭐️ Like</button></div>
|
||||
{% elif activity.fedireads_type == 'Review' %}
|
||||
{% if activity.status_type == 'Review' %}
|
||||
{# display a review #}
|
||||
reviewed {{ activity.book.data.title }}
|
||||
</h2>
|
||||
<div class="book-preview review">
|
||||
{% include 'snippets/book.html' with book=activity.book size=large %}
|
||||
|
||||
<h3>{{ activity.content.name }}</h3>
|
||||
<p>{{ activity.content.rating | stars }}</p>
|
||||
<p>{{ activity.content.content | safe }}</p>
|
||||
<h3>{{ activity.name }}</h3>
|
||||
<p>{{ activity.rating | stars }}</p>
|
||||
<p>{{ activity.content | safe }}</p>
|
||||
</div>
|
||||
<div class="interaction"><button>⭐️ Like</button></div>
|
||||
{% elif activity.activity_type == 'Follow' %}
|
||||
started following someone
|
||||
</h2>
|
||||
{% elif activity.activity_type == 'Note' %}
|
||||
<div class="interaction">
|
||||
<button>⭐️ Like</button>
|
||||
<form name="comment" action="/comment" method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="review" value="{{ activity.id }}"></input>
|
||||
{{ comment_form.content }}
|
||||
<button type="submit">Comment</button>
|
||||
</form>
|
||||
</div>
|
||||
{% elif activity.status_type == 'Note' %}
|
||||
posted</h2>
|
||||
{{ activity.content.object.content | safe }}
|
||||
{{ activity.content | safe }}
|
||||
{% for book in activity.mention_books.all %}
|
||||
<div class="book-preview review">
|
||||
{% include 'snippets/book.html' with book=book size=large description=True %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{# generic handling for a misc activity, which perhaps should not be displayed at all #}
|
||||
did {{ activity.activity_type }}
|
||||
|
|
|
@ -92,7 +92,7 @@
|
|||
<img src="/images/{{ book.cover }}" class="book-cover small">
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ book.openlibrary_key }}">{{ book.data.title }}</a>
|
||||
<a href="/book/{{ book.openlibrary_key }}">{{ book.data.title }}</a>
|
||||
</td>
|
||||
<td>
|
||||
{{ book.authors.first.data.name }}
|
||||
|
|
|
@ -17,14 +17,17 @@ urlpatterns = [
|
|||
re_path(r'^user/(?P<username>\w+)/followers/?$', incoming.get_followers),
|
||||
re_path(r'^user/(?P<username>\w+)/following/?$', incoming.get_following),
|
||||
re_path(
|
||||
r'^user/(?P<username>\w+)/status/(?P<status_id>\d+)/?$',
|
||||
r'^user/(?P<username>\w+)/(status|review)/(?P<status_id>\d+)/?$',
|
||||
incoming.get_status
|
||||
),
|
||||
re_path(
|
||||
r'^user/(?P<username>\w+)/status/(?P<status_id>\d+)/activity/?$',
|
||||
r'^user/(?P<username>\w+)/(status|review)/(?P<status_id>\d+)/activity/?$',
|
||||
incoming.get_status
|
||||
),
|
||||
re_path(r'^user/(?P<username>\w+)/status/?$', incoming.get_following),
|
||||
re_path(
|
||||
r'^user/(?P<username>\w+)/(status|review)/(?P<status_id>\d+)/replies/?$',
|
||||
incoming.get_replies
|
||||
),
|
||||
# TODO: shelves need pages in the UI and for their activitypub Collection
|
||||
|
||||
# .well-known endpoints
|
||||
|
@ -47,6 +50,7 @@ urlpatterns = [
|
|||
|
||||
# internal action endpoints
|
||||
re_path(r'^review/?$', views.review),
|
||||
re_path(r'^comment/?$', views.comment),
|
||||
re_path(
|
||||
r'^shelve/(?P<username>\w+)/(?P<shelf_id>[\w-]+)/(?P<book_id>\d+)/?$',
|
||||
views.shelve
|
||||
|
|
21
fedireads/utils/models.py
Normal file
21
fedireads/utils/models.py
Normal file
|
@ -0,0 +1,21 @@
|
|||
from django.db import models
|
||||
|
||||
from fedireads.settings import DOMAIN
|
||||
|
||||
|
||||
class FedireadsModel(models.Model):
|
||||
content = models.TextField(blank=True, null=True)
|
||||
created_date = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
@property
|
||||
def absolute_id(self):
|
||||
''' constructs the absolute reference to any db object '''
|
||||
base_path = 'https://%s' % DOMAIN
|
||||
if self.user:
|
||||
base_path = self.user.absolute_id
|
||||
model_name = type(self).__name__.lower()
|
||||
return '%s/%s/%d' % (base_path, model_name, self.id)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
|
@ -29,22 +29,21 @@ def home(request):
|
|||
|
||||
# books new to the instance, for discovery
|
||||
recent_books = models.Book.objects.order_by(
|
||||
'-added_date'
|
||||
'-created_date'
|
||||
)[:5]
|
||||
|
||||
# status updates for your follow network
|
||||
following = models.User.objects.filter(
|
||||
Q(followers=request.user) | Q(id=request.user.id)
|
||||
)
|
||||
# TODO: this is fundamentally not how the feed should work I think? it
|
||||
# should do something smart with inboxes. (in this implementation it would
|
||||
# show DMs meant for other local users)
|
||||
activities = models.Activity.objects.filter(
|
||||
user__in=following,
|
||||
|
||||
activities = models.Status.objects.filter(
|
||||
Q(user__in=following, privacy='public') | Q(mention_users=request.user)
|
||||
).select_subclasses().order_by(
|
||||
'-created_date'
|
||||
)[:10]
|
||||
|
||||
comment_form = forms.CommentForm()
|
||||
data = {
|
||||
'user': request.user,
|
||||
'reading': reading,
|
||||
|
@ -52,6 +51,7 @@ def home(request):
|
|||
'recent_books': recent_books,
|
||||
'user_books': user_books,
|
||||
'activities': activities,
|
||||
'comment_form': comment_form,
|
||||
}
|
||||
return TemplateResponse(request, 'feed.html', data)
|
||||
|
||||
|
@ -252,6 +252,18 @@ def review(request):
|
|||
return redirect('/book/%s' % book_identifier)
|
||||
|
||||
|
||||
def comment(request):
|
||||
''' respond to a book review '''
|
||||
form = forms.CommentForm(request.POST)
|
||||
# this is a bit of a formality, the form is just one text field
|
||||
if not form.is_valid():
|
||||
return redirect('/')
|
||||
review_id = request.POST['review']
|
||||
parent = models.Review.objects.get(id=review_id)
|
||||
outgoing.handle_comment(request.user, parent, form.data['content'])
|
||||
return redirect('/')
|
||||
|
||||
|
||||
@login_required
|
||||
def follow(request):
|
||||
''' follow another user, here or abroad '''
|
||||
|
|
Loading…
Reference in a new issue