move things into different files

This commit is contained in:
Mouse Reeve 2020-02-11 15:17:21 -08:00
parent 28198e628c
commit a1fbba1ba3
12 changed files with 330 additions and 315 deletions

View file

@ -37,13 +37,9 @@ application endpoints (things that happen when you click buttons), and federatio
The application views and actions are in `fedireads/views.py`. The internal actions call api handlers which deal with federating content.
Outgoing messages (any action done by a user that is federated out), as well as outboxes, live in `fedireads/outgoing.py`, and all handlers for incoming
messages, as well as inboxes and webfinger, live in `fedireads/incoming.py`. Misc api functions live in `fedireads/api.py`, which is
probably not a good name for that file.
messages, as well as inboxes and webfinger, live in `fedireads/incoming.py`. Connection to openlibrary.org to get book data is handled in `fedireads/openlibrary.py`.
Connection to openlibrary.org to get book data is handled in `fedireads/openlibrary.py`.
The UI is all django templates because I tried to install jinja2 and couldn't get it working so I gave up. It'd be nice to have
jinja2 for macros, so maybe I'll try again some day. You can replace it with a complex javascript framework over my ~dead body~ mild objections.
The UI is all django templates because that is the default. You can replace it with a complex javascript framework over my ~dead body~ mild objections.
## Thoughts and considerations

View file

@ -1,4 +1,4 @@
''' api utilties '''
''' send out activitypub messages '''
from base64 import b64encode
from Crypto.PublicKey import RSA
from Crypto.Signature import pkcs1_15
@ -6,57 +6,11 @@ from Crypto.Hash import SHA256
from datetime import datetime
import json
import requests
from urllib.parse import urlparse
from fedireads import models
from fedireads import incoming
from fedireads.settings import DOMAIN
def get_or_create_remote_user(actor):
''' look up a remote user or add them '''
try:
return models.User.objects.get(actor=actor)
except models.User.DoesNotExist:
pass
# TODO: also bring in the user's prevous reviews and books
# load the user's info from the actor url
response = requests.get(
actor,
headers={'Accept': 'application/activity+json'}
)
if not response.ok:
response.raise_for_status()
data = response.json()
# the webfinger format for the username.
# TODO: get the user's domain in a better way
actor_parts = urlparse(actor)
username = '%s@%s' % (actor_parts.path.split('/')[-1], actor_parts.netloc)
shared_inbox = data.get('endpoints').get('sharedInbox') if \
data.get('endpoints') else None
try:
user = models.User.objects.create_user(
username,
'', '', # email and passwords are left blank
actor=actor,
name=data.get('name'),
summary=data.get('summary'),
inbox=data['inbox'], #fail if there's no inbox
outbox=data['outbox'], # fail if there's no outbox
shared_inbox=shared_inbox,
# TODO: probably shouldn't bother to store this for remote users
public_key=data.get('publicKey').get('publicKeyPem'),
local=False
)
except KeyError:
return False
return user
def get_recipients(user, post_privacy, direct_recipients=None):
''' deduplicated list of recipient inboxes '''
recipients = direct_recipients or []

View file

@ -1,5 +1,6 @@
''' usin django model forms '''
from django.forms import ModelForm, PasswordInput
from django.core.validators import MaxValueValidator, MinValueValidator
from django.forms import ModelForm, PasswordInput, IntegerField
from fedireads import models
@ -29,6 +30,9 @@ class ReviewForm(ModelForm):
model = models.Review
fields = ['name', 'review_content', 'rating']
help_texts = {f: None for f in fields}
review_content = IntegerField(validators=[
MinValueValidator(0), MaxValueValidator(5)
])
labels = {
'name': 'Title',
'review_content': 'Review',

View file

@ -11,17 +11,11 @@ import requests
from uuid import uuid4
from fedireads import models
from fedireads.api import get_or_create_remote_user
from fedireads.remote_user import get_or_create_remote_user
from fedireads.openlibrary import get_or_create_book
from fedireads.settings import DOMAIN
# TODO: this should probably live somewhere else
class HttpResponseUnauthorized(HttpResponse):
''' http response for authentication failure '''
status_code = 401
def webfinger(request):
''' allow other servers to ask about a user '''
resource = request.GET.get('resource')
@ -72,7 +66,7 @@ def shared_inbox(request):
headers={'Accept': 'application/activity+json'}
)
if not response.ok:
return HttpResponseUnauthorized()
return HttpResponse(status=401)
actor = response.json()
key = RSA.import_key(actor['publicKey']['publicKeyPem'])
@ -94,7 +88,7 @@ def shared_inbox(request):
try:
signer.verify(digest, signature)
except ValueError:
return HttpResponseUnauthorized()
return HttpResponse(status=401)
if activity['type'] == 'Add':
return handle_incoming_shelve(activity)

View file

@ -1,13 +1,13 @@
# Generated by Django 3.0.2 on 2020-02-11 05:57
# Generated by Django 3.0.3 on 2020-02-12 01:47
from django.conf import settings
import django.contrib.auth.models
import django.contrib.auth.validators
import django.contrib.postgres.fields.jsonb
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import fedireads.models
class Migration(migrations.Migration):
@ -190,8 +190,8 @@ class Migration(migrations.Migration):
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')),
('name', models.CharField(max_length=255)),
('rating', models.IntegerField(default=0, validators=[fedireads.models.validate_rating])),
('review_content', models.TextField()),
('rating', models.IntegerField(default=0, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(5)])),
('review_content', models.TextField(blank=True, null=True)),
('book', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fedireads.Book')),
],
bases=('fedireads.activity',),

View file

@ -1,246 +0,0 @@
''' database schema for the whole dang thing '''
from django.db import models
from model_utils.managers import InheritanceManager
from django.dispatch import receiver
from django.contrib.auth.models import AbstractUser
from django.contrib.postgres.fields import JSONField
from django.core.exceptions import ValidationError
from Crypto import Random
from Crypto.PublicKey import RSA
import re
from fedireads.settings import DOMAIN, OL_URL
class User(AbstractUser):
''' a user who wants to read books '''
private_key = models.TextField(blank=True, null=True)
public_key = models.TextField(blank=True, null=True)
api_key = models.CharField(max_length=255, blank=True, null=True)
actor = models.CharField(max_length=255, unique=True)
inbox = models.CharField(max_length=255, unique=True)
shared_inbox = models.CharField(max_length=255, blank=True, null=True)
federated_server = models.ForeignKey(
'FederatedServer',
on_delete=models.PROTECT,
null=True,
)
outbox = models.CharField(max_length=255, unique=True)
summary = models.TextField(blank=True, null=True)
local = models.BooleanField(default=True)
localname = models.CharField(
max_length=255,
null=True,
unique=True
)
# name is your display name, which you can change at will
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)
@receiver(models.signals.pre_save, sender=User)
def execute_before_save(sender, instance, *args, **kwargs):
''' create shelves for new users '''
# this user already exists, no need to poplate fields
if instance.id or not instance.local:
return
# 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.shared_inbox = 'https://%s/inbox' % DOMAIN
instance.outbox = 'https://%s/user/%s/outbox' % (DOMAIN, instance.localname)
if not instance.private_key:
random_generator = Random.new().read
key = RSA.generate(1024, random_generator)
instance.private_key = key.export_key().decode('utf8')
instance.public_key = key.publickey().export_key().decode('utf8')
@receiver(models.signals.post_save, sender=User)
def execute_after_save(sender, instance, created, *args, **kwargs):
''' create shelves for new users '''
# TODO: how are remote users handled? what if they aren't readers?
if not instance.local or not created:
return
shelves = [{
'name': 'To Read',
'type': 'to-read',
}, {
'name': 'Currently Reading',
'type': 'reading',
}, {
'name': 'Read',
'type': 'read',
}]
for shelf in shelves:
Shelf(
name=shelf['name'],
shelf_type=shelf['type'],
user=instance,
editable=False
).save()
class FederatedServer(models.Model):
''' store which server's we federate with '''
server_name = models.CharField(max_length=255, unique=True)
shared_inbox = models.CharField(max_length=255, unique=True)
# federated, blocked, whatever else
status = models.CharField(max_length=255, default='federated')
# is it mastodon, fedireads, etc
application_type = models.CharField(max_length=255, null=True)
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)
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)
def validate_rating(rating):
''' only accept 0-5 as star rating ints '''
if rating < 0 or rating > 5:
raise ValidationError('Rating must be 0-5')
class Review(Activity):
''' a book review '''
book = models.ForeignKey('Book', on_delete=models.PROTECT)
name = models.CharField(max_length=255)
rating = models.IntegerField(default=0, validators=[validate_rating])
review_content = models.TextField()
def save(self, *args, **kwargs):
self.activity_type = 'Article'
self.fedireads_type = 'Review'
super().save(*args, **kwargs)
class Note(Activity):
''' reply to a review, etc '''
def save(self, *args, **kwargs):
self.activity_type = 'Note'
super().save(*args, **kwargs)
class Shelf(models.Model):
activitypub_id = models.CharField(max_length=255)
identifier = models.CharField(max_length=255, unique=True)
name = models.CharField(max_length=100)
user = models.ForeignKey('User', on_delete=models.PROTECT)
editable = models.BooleanField(default=True)
shelf_type = models.CharField(default='custom', max_length=100)
books = models.ManyToManyField(
'Book',
symmetrical=False,
through='ShelfBook',
through_fields=('shelf', 'book')
)
created_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ('user', 'name')
def save(self, *args, **kwargs):
if not self.identifier:
self.identifier = '%s_%s' % (
self.user.localname,
re.sub(r'\W', '-', self.name).lower()
)
if not self.activitypub_id:
self.activitypub_id = 'https://%s/shelf/%s' % \
(DOMAIN, self.identifier)
super().save(*args, **kwargs)
class ShelfBook(models.Model):
# 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)
added_by = models.ForeignKey(
'User',
blank=True,
null=True,
on_delete=models.PROTECT
)
added_date = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ('book', 'shelf')
class Book(models.Model):
''' a non-canonical copy of a work (not book) from open library '''
activitypub_id = models.CharField(max_length=255)
openlibrary_key = models.CharField(max_length=255, unique=True)
data = JSONField()
authors = models.ManyToManyField('Author')
# TODO: also store cover thumbnail
cover = models.ImageField(upload_to='covers/', blank=True, null=True)
shelves = models.ManyToManyField(
'Shelf',
symmetrical=False,
through='ShelfBook',
through_fields=('book', 'shelf')
)
added_by = models.ForeignKey(
'User',
blank=True,
null=True,
on_delete=models.PROTECT
)
added_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True)
def save(self, *args, **kwargs):
self.activitypub_id = '%s%s' % (OL_URL, self.openlibrary_key)
super().save(*args, **kwargs)
class Author(models.Model):
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

@ -0,0 +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, Review, Note

View file

@ -0,0 +1,66 @@
''' models for storing different kinds of Activities '''
from django.contrib.postgres.fields import JSONField
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from model_utils.managers import InheritanceManager
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)
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 Review(Activity):
''' a book review '''
book = models.ForeignKey('Book', on_delete=models.PROTECT)
name = models.CharField(max_length=255)
rating = models.IntegerField(default=0, validators=[MinValueValidator(0), MaxValueValidator(5)])
review_content = models.TextField(blank=True, null=True)
def save(self, *args, **kwargs):
self.activity_type = 'Article'
self.fedireads_type = 'Review'
super().save(*args, **kwargs)
class Note(Activity):
''' reply to a review, etc '''
def save(self, *args, **kwargs):
self.activity_type = 'Note'
super().save(*args, **kwargs)

94
fedireads/models/book.py Normal file
View file

@ -0,0 +1,94 @@
''' database schema for the whole dang thing '''
from django.db import models
from model_utils.managers import InheritanceManager
from django.dispatch import receiver
from django.contrib.auth.models import AbstractUser
from django.contrib.postgres.fields import JSONField
from django.core.exceptions import ValidationError
from Crypto import Random
from Crypto.PublicKey import RSA
import re
from fedireads.settings import DOMAIN, OL_URL
class Shelf(models.Model):
activitypub_id = models.CharField(max_length=255)
identifier = models.CharField(max_length=255, unique=True)
name = models.CharField(max_length=100)
user = models.ForeignKey('User', on_delete=models.PROTECT)
editable = models.BooleanField(default=True)
shelf_type = models.CharField(default='custom', max_length=100)
books = models.ManyToManyField(
'Book',
symmetrical=False,
through='ShelfBook',
through_fields=('shelf', 'book')
)
created_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ('user', 'name')
def save(self, *args, **kwargs):
if not self.identifier:
self.identifier = '%s_%s' % (
self.user.localname,
re.sub(r'\W', '-', self.name).lower()
)
if not self.activitypub_id:
self.activitypub_id = 'https://%s/shelf/%s' % \
(DOMAIN, self.identifier)
super().save(*args, **kwargs)
class ShelfBook(models.Model):
# 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)
added_by = models.ForeignKey(
'User',
blank=True,
null=True,
on_delete=models.PROTECT
)
added_date = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ('book', 'shelf')
class Book(models.Model):
''' a non-canonical copy of a work (not book) from open library '''
activitypub_id = models.CharField(max_length=255)
openlibrary_key = models.CharField(max_length=255, unique=True)
data = JSONField()
authors = models.ManyToManyField('Author')
# TODO: also store cover thumbnail
cover = models.ImageField(upload_to='covers/', blank=True, null=True)
shelves = models.ManyToManyField(
'Shelf',
symmetrical=False,
through='ShelfBook',
through_fields=('book', 'shelf')
)
added_by = models.ForeignKey(
'User',
blank=True,
null=True,
on_delete=models.PROTECT
)
added_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True)
def save(self, *args, **kwargs):
self.activitypub_id = '%s%s' % (OL_URL, self.openlibrary_key)
super().save(*args, **kwargs)
class Author(models.Model):
openlibrary_key = models.CharField(max_length=255)
data = JSONField()
added_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True)

97
fedireads/models/user.py Normal file
View file

@ -0,0 +1,97 @@
''' database schema for user data '''
from Crypto import Random
from Crypto.PublicKey import RSA
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.dispatch import receiver
from fedireads.models import Shelf
from fedireads.settings import DOMAIN
class User(AbstractUser):
''' a user who wants to read books '''
private_key = models.TextField(blank=True, null=True)
public_key = models.TextField(blank=True, null=True)
api_key = models.CharField(max_length=255, blank=True, null=True)
actor = models.CharField(max_length=255, unique=True)
inbox = models.CharField(max_length=255, unique=True)
shared_inbox = models.CharField(max_length=255, blank=True, null=True)
federated_server = models.ForeignKey(
'FederatedServer',
on_delete=models.PROTECT,
null=True,
)
outbox = models.CharField(max_length=255, unique=True)
summary = models.TextField(blank=True, null=True)
local = models.BooleanField(default=True)
localname = models.CharField(
max_length=255,
null=True,
unique=True
)
# name is your display name, which you can change at will
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)
class FederatedServer(models.Model):
''' store which server's we federate with '''
server_name = models.CharField(max_length=255, unique=True)
shared_inbox = models.CharField(max_length=255, unique=True)
# federated, blocked, whatever else
status = models.CharField(max_length=255, default='federated')
# is it mastodon, fedireads, etc
application_type = models.CharField(max_length=255, null=True)
@receiver(models.signals.pre_save, sender=User)
def execute_before_save(sender, instance, *args, **kwargs):
''' create shelves for new users '''
# this user already exists, no need to poplate fields
if instance.id or not instance.local:
return
# 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.shared_inbox = 'https://%s/inbox' % DOMAIN
instance.outbox = 'https://%s/user/%s/outbox' % (DOMAIN, instance.localname)
if not instance.private_key:
random_generator = Random.new().read
key = RSA.generate(1024, random_generator)
instance.private_key = key.export_key().decode('utf8')
instance.public_key = key.publickey().export_key().decode('utf8')
@receiver(models.signals.post_save, sender=User)
def execute_after_save(sender, instance, created, *args, **kwargs):
''' create shelves for new users '''
# TODO: how are remote users handled? what if they aren't readers?
if not instance.local or not created:
return
shelves = [{
'name': 'To Read',
'type': 'to-read',
}, {
'name': 'Currently Reading',
'type': 'reading',
}, {
'name': 'Read',
'type': 'read',
}]
for shelf in shelves:
Shelf(
name=shelf['name'],
shelf_type=shelf['type'],
user=instance,
editable=False
).save()

View file

@ -7,8 +7,8 @@ from urllib.parse import urlencode
from uuid import uuid4
from fedireads import models
from fedireads.api import get_or_create_remote_user, get_recipients, \
broadcast
from fedireads.remote_user import get_or_create_remote_user
from fedireads.broadcast import get_recipients, broadcast
from fedireads.settings import DOMAIN

51
fedireads/remote_user.py Normal file
View file

@ -0,0 +1,51 @@
''' manage remote users '''
import requests
from urllib.parse import urlparse
from fedireads import models
def get_or_create_remote_user(actor):
''' look up a remote user or add them '''
try:
return models.User.objects.get(actor=actor)
except models.User.DoesNotExist:
pass
# TODO: also bring in the user's prevous reviews and books
# load the user's info from the actor url
response = requests.get(
actor,
headers={'Accept': 'application/activity+json'}
)
if not response.ok:
response.raise_for_status()
data = response.json()
# the webfinger format for the username.
# TODO: get the user's domain in a better way
actor_parts = urlparse(actor)
username = '%s@%s' % (actor_parts.path.split('/')[-1], actor_parts.netloc)
shared_inbox = data.get('endpoints').get('sharedInbox') if \
data.get('endpoints') else None
try:
user = models.User.objects.create_user(
username,
'', '', # email and passwords are left blank
actor=actor,
name=data.get('name'),
summary=data.get('summary'),
inbox=data['inbox'], #fail if there's no inbox
outbox=data['outbox'], # fail if there's no outbox
shared_inbox=shared_inbox,
# TODO: probably shouldn't bother to store this for remote users
public_key=data.get('publicKey').get('publicKeyPem'),
local=False
)
except KeyError:
return False
return user