Merge pull request #11 from mouse-reeve/stop-storing-ap-json

Stop storing ap json
This commit is contained in:
Mouse Reeve 2020-02-18 17:32:09 -08:00 committed by GitHub
commit 978545717e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 630 additions and 476 deletions

View 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

View 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,
}
}

View 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,
}
}

View 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'),
}
}

View 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,
}

View 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]
}
}

View file

@ -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(

View file

@ -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}

View file

@ -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()

View file

@ -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',),
),
]

View 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),
),
]

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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()

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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
View 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

View file

@ -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 '''