Merge branch 'main' into production

This commit is contained in:
Mouse Reeve 2020-11-01 11:12:02 -08:00
commit 875b473711
19 changed files with 235 additions and 54 deletions

View file

@ -3,8 +3,9 @@ import inspect
import sys
from .base_activity import ActivityEncoder, Image, PublicKey, Signature
from .base_activity import Link, Mention
from .note import Note, GeneratedNote, Article, Comment, Review, Quotation
from .note import Tombstone, Link
from .note import Tombstone
from .interaction import Boost, Like
from .ordered_collection import OrderedCollection, OrderedCollectionPage
from .person import Person

View file

@ -21,6 +21,19 @@ class Image:
type: str = 'Image'
@dataclass
class Link():
''' for tagging a book in a status '''
href: str
name: str
type: str = 'Link'
@dataclass
class Mention(Link):
''' a subtype of Link for mentioning an actor '''
type: str = 'Mention'
@dataclass
class PublicKey:
''' public key block '''

View file

@ -2,7 +2,7 @@
from dataclasses import dataclass, field
from typing import Dict, List
from .base_activity import ActivityObject, Image
from .base_activity import ActivityObject, Image, Link
@dataclass(init=False)
class Tombstone(ActivityObject):
@ -20,6 +20,7 @@ class Note(ActivityObject):
inReplyTo: str
published: str
attributedTo: str
tag: List[Link]
to: List[str]
cc: List[str]
content: str
@ -36,17 +37,9 @@ class Article(Note):
type: str = 'Article'
@dataclass
class Link():
''' for tagging a book in a status '''
href: str
name: str
type: str = 'Link'
@dataclass(init=False)
class GeneratedNote(Note):
''' just a re-typed note '''
tag: List[Link]
type: str = 'GeneratedNote'

View file

@ -230,7 +230,7 @@ def handle_create(activity):
if hasattr(activity, 'inReplyToBook'):
book_urls.append(activity.inReplyToBook)
if hasattr(activity, 'tag'):
book_urls += [t.href for t in activity.tag if t.type == 'Book']
book_urls += [t['href'] for t in activity.tag if t['type'] == 'Book']
for remote_id in book_urls:
books_manager.get_or_create_book(remote_id)

View file

@ -0,0 +1,26 @@
# Generated by Django 3.0.7 on 2020-11-01 17:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0062_auto_20201031_1936'),
]
operations = [
migrations.RemoveConstraint(
model_name='notification',
name='notification_type_valid',
),
migrations.AlterField(
model_name='notification',
name='notification_type',
field=models.CharField(choices=[('FAVORITE', 'Favorite'), ('REPLY', 'Reply'), ('MENTION', 'Mention'), ('TAG', 'Tag'), ('FOLLOW', 'Follow'), ('FOLLOW_REQUEST', 'Follow Request'), ('BOOST', 'Boost'), ('IMPORT', 'Import')], max_length=255),
),
migrations.AddConstraint(
model_name='notification',
constraint=models.CheckConstraint(check=models.Q(notification_type__in=['FAVORITE', 'REPLY', 'MENTION', 'TAG', 'FOLLOW', 'FOLLOW_REQUEST', 'BOOST', 'IMPORT']), name='notification_type_valid'),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 3.0.7 on 2020-11-01 17:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0062_auto_20201031_1936'),
]
operations = [
migrations.AddField(
model_name='user',
name='last_active_date',
field=models.DateTimeField(auto_now=True),
),
]

View file

@ -59,6 +59,8 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
@property
def ap_tag(self):
''' references to books and/or users '''
tags = []
for book in self.mention_books.all():
tags.append(activitypub.Link(
@ -66,6 +68,11 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
name=book.title,
type='Book'
))
for user in self.mention_users.all():
tags.append(activitypub.Mention(
href=user.remote_id,
name=user.username,
))
return tags
shared_mappings = [
@ -117,7 +124,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
**kwargs
)
def to_activity(self, **kwargs):
def to_activity(self, pure=False):
''' return tombstone if the status is deleted '''
if self.deleted:
return activitypub.Tombstone(
@ -126,7 +133,12 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
deleted=self.deleted_date.isoformat(),
published=self.deleted_date.isoformat()
).serialize()
return ActivitypubMixin.to_activity(self, **kwargs)
return ActivitypubMixin.to_activity(self, pure=pure)
def save(self, *args, **kwargs):
self.user.last_active_date = timezone.now()
self.user.save()
super().save(*args, **kwargs)
class GeneratedNote(Status):
@ -227,6 +239,11 @@ class Favorite(ActivitypubMixin, BookWyrmModel):
activity_serializer = activitypub.Like
def save(self, *args, **kwargs):
self.user.last_active_date = timezone.now()
self.user.save()
super().save(*args, **kwargs)
class Meta:
''' can't fav things twice '''
@ -267,10 +284,15 @@ class ReadThrough(BookWyrmModel):
blank=True,
null=True)
def save(self, *args, **kwargs):
self.user.last_active_date = timezone.now()
self.user.save()
super().save(*args, **kwargs)
NotificationType = models.TextChoices(
'NotificationType',
'FAVORITE REPLY TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT')
'FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT')
class Notification(BookWyrmModel):
''' you've been tagged, liked, followed, etc '''

View file

@ -69,6 +69,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
remote_id = models.CharField(max_length=255, null=True, unique=True)
created_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True)
last_active_date = models.DateTimeField(auto_now=True)
manually_approves_followers = models.BooleanField(default=False)
# ---- activitypub serialization settings for this model ----- #
@ -167,28 +168,30 @@ class User(OrderedCollectionPageMixin, AbstractUser):
return activity_object
@receiver(models.signals.pre_save, sender=User)
def execute_before_save(sender, instance, *args, **kwargs):
''' populate fields for new local users '''
# this user already exists, no need to poplate fields
if instance.id:
return
if not instance.local:
# we need to generate a username that uses the domain (webfinger format)
actor_parts = urlparse(instance.remote_id)
instance.username = '%s@%s' % (instance.username, actor_parts.netloc)
return
def save(self, *args, **kwargs):
''' populate fields for new local users '''
# this user already exists, no need to populate fields
if self.id:
return
# populate fields for local users
instance.remote_id = 'https://%s/user/%s' % (DOMAIN, instance.username)
instance.localname = instance.username
instance.username = '%s@%s' % (instance.username, DOMAIN)
instance.actor = instance.remote_id
instance.inbox = '%s/inbox' % instance.remote_id
instance.shared_inbox = 'https://%s/inbox' % DOMAIN
instance.outbox = '%s/outbox' % instance.remote_id
if not instance.private_key:
instance.private_key, instance.public_key = create_key_pair()
if not self.local:
# generate a username that uses the domain (webfinger format)
actor_parts = urlparse(self.remote_id)
self.username = '%s@%s' % (self.username, actor_parts.netloc)
return
# populate fields for local users
self.remote_id = 'https://%s/user/%s' % (DOMAIN, self.username)
self.localname = self.username
self.username = '%s@%s' % (self.username, DOMAIN)
self.actor = self.remote_id
self.inbox = '%s/inbox' % self.remote_id
self.shared_inbox = 'https://%s/inbox' % DOMAIN
self.outbox = '%s/outbox' % self.remote_id
if not self.private_key:
self.private_key, self.public_key = create_key_pair()
super().save(*args, **kwargs)
@receiver(models.signals.post_save, sender=User)

View file

@ -1,5 +1,6 @@
''' handles all the activity coming out of the server '''
from datetime import datetime
import re
from django.db import IntegrityError, transaction
from django.http import HttpResponseNotFound, JsonResponse
@ -13,6 +14,8 @@ from bookwyrm.status import create_tag, create_notification
from bookwyrm.status import create_generated_note
from bookwyrm.status import delete_status
from bookwyrm.remote_user import get_or_create_remote_user
from bookwyrm.settings import DOMAIN
from bookwyrm.utils import regex
@csrf_exempt
@ -34,13 +37,17 @@ def outbox(request, username):
def handle_remote_webfinger(query):
''' webfingerin' other servers '''
''' webfingerin' other servers, username query should be user@domain '''
user = None
domain = query.split('@')[1]
try:
domain = query.split('@')[2]
except IndexError:
return None
try:
user = models.User.objects.get(username=query)
except models.User.DoesNotExist:
url = 'https://%s/.well-known/webfinger?resource=acct:%s' % \
url = 'https://%s/.well-known/webfinger?resource=acct:@%s' % \
(domain, query)
try:
response = requests.get(url)
@ -55,7 +62,7 @@ def handle_remote_webfinger(query):
user = get_or_create_remote_user(link['href'])
except KeyError:
return None
return [user]
return user
def handle_follow(user, to_follow):
@ -211,7 +218,36 @@ def handle_status(user, form):
''' generic handler for statuses '''
status = form.save()
# notify reply parent or (TODO) tagged users
# inspect the text for user tags
text = status.content
matches = re.finditer(
regex.username,
text
)
for match in matches:
username = match.group().strip().split('@')[1:]
if len(username) == 1:
# this looks like a local user (@user), fill in the domain
username.append(DOMAIN)
username = '@'.join(username)
mention_user = handle_remote_webfinger(username)
if not mention_user:
# we can ignore users we don't know about
continue
# add them to status mentions fk
status.mention_users.add(mention_user)
# create notification if the mentioned user is local
if mention_user.local:
create_notification(
mention_user,
'MENTION',
related_user=user,
related_status=status
)
status.save()
# notify reply parent or tagged users
if status.reply_parent and status.reply_parent.user.local:
create_notification(
status.reply_parent.user,

View file

@ -113,7 +113,7 @@ input.toggle-control:checked ~ .toggle-content {
left: 0;
}
.quote blockquote:after {
content: "\e903";
content: "\e904";
right: 0;
}

View file

@ -22,6 +22,10 @@
favorited your
<a href="{{ notification.related_status.remote_id}}">status</a>
{% elif notification.notification_type == 'MENTION' %}
mentioned you in a
<a href="{{ notification.related_status.remote_id}}">status</a>
{% elif notification.notification_type == 'REPLY' %}
<a href="{{ notification.related_status.remote_id}}">replied</a>
to your

View file

@ -1,2 +1,20 @@
<blockquote class="content">{% if book.description %}{{ book.description }}{% elif book.parent_work.description %}{{ book.parent_work.description }}{% endif %}</blockquote>
{% load fr_display %}
{% with book|book_description as full %}
{% with full|text_overflow as trimmed %}
{% if trimmed != full %}
<div>
<input type="radio" name="show-hide-{{ book.id }}" id="show-{{ book.id }}" class="toggle-control" checked>
<blockquote class="content toggle-content hidden">{{ trimmed }}
<label class="button is-small" for="hide-{{ book.id }}">show more</label></blockquote>
</div>
<div>
<input type="radio" name="show-hide-{{ book.id }}" id="hide-{{ book.id }}" class="toggle-control">
<blockquote class="content toggle-content hidden">{{ full }}
<label class="button is-small" for="show-{{ book.id }}">show less</label></blockquote>
</div>
{% else %}
<blockquote class="content">{{ full }}
</blockquote>
{% endif %}
{% endwith %}
{% endwith %}

View file

@ -106,6 +106,25 @@ def get_edition_info(book):
return ', '.join(i for i in items if i)
@register.filter(name='book_description')
def get_book_description(book):
''' use the work's text if the book doesn't have it '''
return book.description or book.parent_work.description
@register.filter(name='text_overflow')
def text_overflow(text):
''' dont' let book descriptions run for ages '''
char_max = 500
if len(text) < char_max:
return text
trimmed = text[:char_max]
# go back to the last space
trimmed = ' '.join(trimmed.split(' ')[:-1])
return trimmed + '...'
@register.simple_tag(takes_context=True)
def shelve_button_identifier(context, book):
''' check what shelf a user has a book on, if any '''

View file

@ -17,7 +17,7 @@ status_types = [
'comment',
'quotation',
'boost',
'generatedstatus'
'generatednote'
]
status_path = r'%s/(%s)/(?P<status_id>\d+)' % \
(local_user_path, '|'.join(status_types))

View file

@ -0,0 +1 @@
from .regex import username

5
bookwyrm/utils/regex.py Normal file
View file

@ -0,0 +1,5 @@
''' defining regexes for regularly used concepts '''
domain = r'[a-z-A-Z0-9_\-]+\.[a-z]+'
username = r'@[a-zA-Z_\-\.0-9]+(@%s)?' % domain
full_username = r'@[a-zA-Z_\-\.0-9]+@%s' % domain

View file

@ -4,13 +4,15 @@ from PIL import Image
import dateutil.parser
from dateutil.parser import ParserError
from django.contrib.auth import authenticate, login, logout
from django.contrib.auth.decorators import login_required, permission_required
from django.core.exceptions import PermissionDenied
from django.core.files.base import ContentFile
from django.http import HttpResponseBadRequest, HttpResponseNotFound
from django.shortcuts import redirect
from django.template.response import TemplateResponse
from django.core.exceptions import PermissionDenied
from django.utils import timezone
from bookwyrm import books_manager
from bookwyrm import forms, models, outgoing
@ -32,7 +34,9 @@ def user_login(request):
password = login_form.data['password']
user = authenticate(request, username=username, password=password)
if user is not None:
# successful login
login(request, user)
user.last_active_date = timezone.now()
return redirect(request.GET.get('next', '/'))
login_form.non_field_errors = 'Username or password are incorrect'

View file

@ -16,6 +16,7 @@ from bookwyrm.activitypub import ActivityEncoder
from bookwyrm import forms, models, books_manager
from bookwyrm import goodreads_import
from bookwyrm.tasks import app
from bookwyrm.utils import regex
def get_user_from_username(username):
@ -168,7 +169,7 @@ def search(request):
return JsonResponse([r.__dict__ for r in book_results], safe=False)
# use webfinger looks like a mastodon style account@domain.com username
if re.match(r'\w+@\w+.\w+', query):
if re.match(regex.full_username, query):
outgoing.handle_remote_webfinger(query)
# do a local user search

View file

@ -1,5 +1,9 @@
''' responds to various requests to /.well-know '''
from django.http import HttpResponseBadRequest, HttpResponseNotFound
from datetime import datetime
from dateutil.relativedelta import relativedelta
from django.http import HttpResponseNotFound
from django.http import JsonResponse
from bookwyrm import models
@ -13,13 +17,16 @@ def webfinger(request):
resource = request.GET.get('resource')
if not resource and not resource.startswith('acct:'):
return HttpResponseBadRequest()
ap_id = resource.replace('acct:', '')
user = models.User.objects.filter(username=ap_id).first()
if not user:
return HttpResponseNotFound()
username = resource.replace('acct:@', '')
try:
user = models.User.objects.get(username=username)
except models.User.DoesNotExist:
return HttpResponseNotFound('No account found')
return JsonResponse({
'subject': 'acct:%s' % (user.username),
'subject': 'acct:@%s' % (user.username),
'links': [
{
'rel': 'self',
@ -52,6 +59,16 @@ def nodeinfo(request):
status_count = models.Status.objects.filter(user__local=True).count()
user_count = models.User.objects.count()
month_ago = datetime.now() - relativedelta(months=1)
last_month_count = models.User.objects.filter(
last_active_date__gt=month_ago
).count()
six_months_ago = datetime.now() - relativedelta(months=6)
six_month_count = models.User.objects.filter(
last_active_date__gt=six_months_ago
).count()
return JsonResponse({
'version': '2.0',
'software': {
@ -64,8 +81,8 @@ def nodeinfo(request):
'usage': {
'users': {
'total': user_count,
'activeMonth': user_count, # TODO
'activeHalfyear': user_count, # TODO
'activeMonth': last_month_count,
'activeHalfyear': six_month_count,
},
'localPosts': status_count,
},