Stop storing activitypub json in db entirely

This commit is contained in:
Mouse Reeve 2020-02-16 18:22:01 -08:00
parent 8d081548cd
commit f28ee934fc
7 changed files with 150 additions and 288 deletions

View file

@ -1,7 +1,14 @@
''' Handle user activity ''' ''' Handle user activity '''
from base64 import b64encode
from Crypto.PublicKey import RSA
from Crypto.Signature import pkcs1_15
from Crypto.Hash import SHA256
from uuid import uuid4
from fedireads import models from fedireads import models
from fedireads.openlibrary import get_or_create_book from fedireads.openlibrary import get_or_create_book
from fedireads.sanitize_html import InputHtmlParser from fedireads.sanitize_html import InputHtmlParser
from fedireads.settings import DOMAIN
def create_review(user, possible_book, name, content, rating): def create_review(user, possible_book, name, content, rating):
@ -17,7 +24,7 @@ def create_review(user, possible_book, name, content, rating):
# no ratings outside of 0-5 # no ratings outside of 0-5
rating = rating if 0 <= rating <= 5 else 0 rating = rating if 0 <= rating <= 5 else 0
review = models.Review.objects.create( return models.Review.objects.create(
user=user, user=user,
book=book, book=book,
name=name, name=name,
@ -25,6 +32,108 @@ def create_review(user, possible_book, name, content, rating):
content=content, content=content,
) )
return review
def create_status(user, content, reply_parent=None):
''' a status update '''
# TODO: handle @'ing users
# sanitize input html
parser = InputHtmlParser()
parser.feed(content)
content = parser.get_output()
return models.Status.objects.create(
user=user,
content=content,
reply_parent=reply_parent,
)
def get_status_json(status):
''' create activitypub json for a status '''
user = status.user
uri = 'https://%s/user/%s/status/%d' % (DOMAIN, user.localname, status.id)
reply_parent_id = status.reply_parent.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': ['https://%s/user/%s/followers' % (DOMAIN, user.localname)],
'sensitive': status.sensitive,
'content': status.content,
'type': status.activity_type,
'fedireadsType': status.status_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
}
}
}
if status.status_type == 'Review':
status_json['name'] = status.name,
status_json['rating'] = status.rating
status_json['fedireadsType'] = status.status_type
return status_json
def get_create_json(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': 'https://%s/user/%s#main-key' % (DOMAIN, user.localname),
'created': status_json['published'],
'signatureValue': b64encode(signed_message).decode('utf8'),
}
}
def get_add_remove_json(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': 'https://%s/user/%s/shelf/%s' % \
(DOMAIN, user.localname, shelf.identifier)
}
}

View file

@ -11,7 +11,7 @@ import requests
from fedireads import models from fedireads import models
from fedireads import outgoing from fedireads import outgoing
from fedireads.activity import create_review from fedireads.activity import create_review, create_status
from fedireads.remote_user import get_or_create_remote_user from fedireads.remote_user import get_or_create_remote_user
@ -33,10 +33,7 @@ def shared_inbox(request):
return HttpResponse(status=401) return HttpResponse(status=401)
response = HttpResponseNotFound() response = HttpResponseNotFound()
if activity['type'] == 'Add': if activity['type'] == 'Follow':
response = handle_incoming_shelve(activity)
elif activity['type'] == 'Follow':
response = handle_incoming_follow(activity) response = handle_incoming_follow(activity)
elif activity['type'] == 'Create': elif activity['type'] == 'Create':
@ -45,7 +42,7 @@ def shared_inbox(request):
elif activity['type'] == 'Accept': elif activity['type'] == 'Accept':
response = handle_incoming_follow_accept(activity) response = handle_incoming_follow_accept(activity)
# TODO: Undo, Remove, etc # TODO: Add, Undo, Remove, etc
return response return response
@ -217,43 +214,12 @@ def get_follow_page(user_list, id_slug, page):
return data 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()
def handle_incoming_follow(activity): def handle_incoming_follow(activity):
''' someone wants to follow a local user ''' ''' someone wants to follow a local user '''
# figure out who they want to follow # figure out who they want to follow
to_follow = models.User.objects.get(actor=activity['object']) to_follow = models.User.objects.get(actor=activity['object'])
# figure out who they are # figure out who they are
user = get_or_create_remote_user(activity['actor']) 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 # TODO: allow users to manually approve requests
outgoing.handle_outgoing_accept(user, to_follow, activity) outgoing.handle_outgoing_accept(user, to_follow, activity)
return HttpResponse() return HttpResponse()
@ -278,36 +244,27 @@ def handle_incoming_create(activity):
return HttpResponseBadRequest() return HttpResponseBadRequest()
response = HttpResponse() response = HttpResponse()
content = activity['object'].get('content')
if activity['object'].get('fedireadsType') == 'Review' and \ if activity['object'].get('fedireadsType') == 'Review' and \
'inReplyTo' in activity['object']: 'inReplyTo' in activity['object']:
book = activity['object']['inReplyTo'] book = activity['object']['inReplyTo']
book = book.split('/')[-1] book = book.split('/')[-1]
name = activity['object'].get('name') name = activity['object'].get('name')
content = activity['object'].get('content')
rating = activity['object'].get('rating') rating = activity['object'].get('rating')
if user.local: if user.local:
review_id = activity['object']['id'].split('/')[-1] review_id = activity['object']['id'].split('/')[-1]
review = models.Review.objects.get(id=review_id) models.Review.objects.get(id=review_id)
else: else:
try: try:
review = create_review(user, book, name, content, rating) create_review(user, book, name, content, rating)
except ValueError: except ValueError:
return HttpResponseBadRequest() return HttpResponseBadRequest()
models.ReviewActivity.objects.create(
uuid=activity['id'],
user=user,
content=activity['object'],
activity_type=activity['object']['type'],
book=review.book,
)
else: else:
models.Activity.objects.create( try:
uuid=activity['id'], create_status(user, content)
user=user, except ValueError:
content=activity, return HttpResponseBadRequest()
activity_type=activity['object']['type']
)
return response return response
@ -321,12 +278,5 @@ def handle_incoming_accept(activity):
# save this relationship in the db # save this relationship in the db
followed.followers.add(user) followed.followers.add(user)
# save the activity record
models.FollowActivity(
uuid=activity['id'],
user=user,
followed=followed,
content=activity,
).save()
return HttpResponse() return HttpResponse()

View file

@ -1,6 +1,5 @@
''' bring all the models into the app namespace ''' ''' bring all the models into the app namespace '''
from .book import Shelf, ShelfBook, Book, Author from .book import Shelf, ShelfBook, Book, Author
from .user import User, FederatedServer from .user import User, FederatedServer
from .activity import Activity, ShelveActivity, FollowActivity, \ from .activity import Status, Review
ReviewActivity, Status, Review

View file

@ -3,66 +3,15 @@ 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.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()
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): class Status(models.Model):
''' 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') status_type = models.CharField(max_length=255, default='Note')
activity = JSONField(max_length=5000, null=True) 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')
sensitive = models.BooleanField(default=False)
reply_parent = models.ForeignKey( reply_parent = models.ForeignKey(
'self', 'self',
null=True, null=True,
@ -70,14 +19,13 @@ class Status(models.Model):
) )
content = models.TextField(blank=True, null=True) content = models.TextField(blank=True, null=True)
created_date = models.DateTimeField(auto_now_add=True) created_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True)
objects = InheritanceManager() objects = InheritanceManager()
class Review(Status): class Review(Status):
''' a book review ''' ''' a book review '''
book = models.ForeignKey('Book', on_delete=models.PROTECT)
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
book = models.ForeignKey('Book', on_delete=models.PROTECT)
rating = models.IntegerField( rating = models.IntegerField(
default=0, default=0,
validators=[MinValueValidator(0), MaxValueValidator(5)] validators=[MinValueValidator(0), MaxValueValidator(5)]
@ -87,4 +35,3 @@ class Review(Status):
self.status_type = 'Review' self.status_type = 'Review'
super().save(*args, **kwargs) super().save(*args, **kwargs)

View file

@ -1,9 +1,4 @@
''' handles all the activity coming out of the server ''' ''' 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.http import HttpResponseNotFound, JsonResponse
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
import requests import requests
@ -11,7 +6,8 @@ from urllib.parse import urlencode
from uuid import uuid4 from uuid import uuid4
from fedireads import models from fedireads import models
from fedireads.activity import create_review from fedireads.activity import create_review, get_status_json, get_create_json
from fedireads.activity import get_add_remove_json
from fedireads.remote_user import get_or_create_remote_user from fedireads.remote_user import get_or_create_remote_user
from fedireads.broadcast import get_recipients, broadcast from fedireads.broadcast import get_recipients, broadcast
from fedireads.settings import DOMAIN from fedireads.settings import DOMAIN
@ -43,19 +39,17 @@ def outbox(request, username):
filters['id__lte'] = max_id filters['id__lte'] = max_id
collection_id = query_path + urlencode(params) collection_id = query_path + urlencode(params)
messages = models.Activity.objects.filter(
user=user,
activity_type__in=['Article', 'Note'],
**filters
).all()[:limit]
outbox_page = { outbox_page = {
'@context': 'https://www.w3.org/ns/activitystreams', '@context': 'https://www.w3.org/ns/activitystreams',
'id': collection_id, 'id': collection_id,
'type': 'OrderedCollectionPage', 'type': 'OrderedCollectionPage',
'partOf': user.outbox, 'partOf': user.outbox,
'orderedItems': [m.content for m in messages], 'orderedItems': [],
} }
statuses = models.Status.objects.filter(user=user, **filters).all()
for status in statuses[:limit]:
outbox_page['orderedItems'].append(get_status_json(status))
if max_id: if max_id:
outbox_page['next'] = query_path + \ outbox_page['next'] = query_path + \
urlencode({'min_id': max_id, 'page': 'true'}) urlencode({'min_id': max_id, 'page': 'true'})
@ -65,7 +59,7 @@ def outbox(request, username):
return JsonResponse(outbox_page) return JsonResponse(outbox_page)
# collection overview # collection overview
size = models.Review.objects.filter(user=user).count() size = models.Status.objects.filter(user=user).count()
return JsonResponse({ return JsonResponse({
'@context': 'https://www.w3.org/ns/activitystreams', '@context': 'https://www.w3.org/ns/activitystreams',
'id': '%s/outbox' % user.actor, 'id': '%s/outbox' % user.actor,
@ -134,90 +128,25 @@ def handle_outgoing_accept(user, to_follow, activity):
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
# TODO: this should probably happen in incoming instead
models.ShelfBook(book=book, shelf=shelf, added_by=user).save() models.ShelfBook(book=book, shelf=shelf, added_by=user).save()
# send out the activitypub action activity = get_add_remove_json(user, book, shelf, 'Add')
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)
}
}
recipients = get_recipients(user, 'public') recipients = get_recipients(user, 'public')
models.ShelveActivity(
uuid=uuid,
user=user,
content=activity,
shelf=shelf,
book=book,
).save()
broadcast(user, activity, recipients) broadcast(user, activity, recipients)
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
# TODO: this should probably happen in incoming instead
row = models.ShelfBook.objects.get(book=book, shelf=shelf) row = models.ShelfBook.objects.get(book=book, shelf=shelf)
row.delete() row.delete()
# send out the activitypub action activity = get_add_remove_json(user, book, shelf, 'Remove')
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)
}
}
recipients = get_recipients(user, 'public') 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) broadcast(user, activity, recipients)
@ -226,63 +155,10 @@ def handle_review(user, book, name, content, rating):
# 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
review = create_review(user, book, name, content, rating) review = create_review(user, book, name, content, rating)
review_path = 'https://%s/user/%s/status/%d' % \ #book_path = 'https://%s/book/%s' % (DOMAIN, review.book.openlibrary_key)
(DOMAIN, user.localname, review.id)
book_path = 'https://%s/book/%s' % (DOMAIN, review.book.openlibrary_key)
now = datetime.utcnow().isoformat() #TODO: should this be http_date? review_activity = get_status_json(review)
review_activity = { create_activity = get_create_json(user, 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()
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',
'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'),
}
}
recipients = get_recipients(user, 'public') recipients = get_recipients(user, 'public')
broadcast(user, create_activity, recipients) broadcast(user, create_activity, recipients)

View file

@ -55,40 +55,21 @@
<h2> <h2>
{% include 'snippets/avatar.html' with user=activity.user %} {% include 'snippets/avatar.html' with user=activity.user %}
{% include 'snippets/username.html' with user=activity.user %} {% include 'snippets/username.html' with user=activity.user %}
{% if activity.fedireads_type == 'Shelve' %} {% if activity.status_type == 'Review' %}
{# 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' %}
{# display a review #} {# display a review #}
reviewed {{ activity.book.data.title }} reviewed {{ activity.book.data.title }}
</h2> </h2>
<div class="book-preview review"> <div class="book-preview review">
{% include 'snippets/book.html' with book=activity.book size=large %} {% include 'snippets/book.html' with book=activity.book size=large %}
<h3>{{ activity.content.name }}</h3> <h3>{{ activity.name }}</h3>
<p>{{ activity.content.rating | stars }}</p> <p>{{ activity.rating | stars }}</p>
<p>{{ activity.content.content | safe }}</p> <p>{{ activity.content | safe }}</p>
</div> </div>
<div class="interaction"><button>⭐️ Like</button></div> <div class="interaction"><button>⭐️ Like</button></div>
{% elif activity.activity_type == 'Follow' %} {% elif activity.status_type == 'Note' %}
started following someone
</h2>
{% elif activity.activity_type == 'Note' %}
posted</h2> posted</h2>
{{ activity.content.object.content | safe }} {{ activity.content | safe }}
{% else %} {% else %}
{# generic handling for a misc activity, which perhaps should not be displayed at all #} {# generic handling for a misc activity, which perhaps should not be displayed at all #}
did {{ activity.activity_type }} did {{ activity.activity_type }}

View file

@ -39,7 +39,7 @@ def home(request):
# TODO: this is fundamentally not how the feed should work I think? it # TODO: this is fundamentally not how the feed should work I think? it
# should do something smart with inboxes. (in this implementation it would # should do something smart with inboxes. (in this implementation it would
# show DMs meant for other local users) # show DMs meant for other local users)
activities = models.Activity.objects.filter( activities = models.Status.objects.filter(
user__in=following, user__in=following,
).select_subclasses().order_by( ).select_subclasses().order_by(
'-created_date' '-created_date'