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 import sys
from .base_activity import ActivityEncoder, Image, PublicKey, Signature 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 Note, GeneratedNote, Article, Comment, Review, Quotation
from .note import Tombstone, Link from .note import Tombstone
from .interaction import Boost, Like from .interaction import Boost, Like
from .ordered_collection import OrderedCollection, OrderedCollectionPage from .ordered_collection import OrderedCollection, OrderedCollectionPage
from .person import Person from .person import Person

View file

@ -21,6 +21,19 @@ class Image:
type: str = '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 @dataclass
class PublicKey: class PublicKey:
''' public key block ''' ''' public key block '''

View file

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

View file

@ -230,7 +230,7 @@ def handle_create(activity):
if hasattr(activity, 'inReplyToBook'): if hasattr(activity, 'inReplyToBook'):
book_urls.append(activity.inReplyToBook) book_urls.append(activity.inReplyToBook)
if hasattr(activity, 'tag'): 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: for remote_id in book_urls:
books_manager.get_or_create_book(remote_id) 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 @property
def ap_tag(self): def ap_tag(self):
''' references to books and/or users '''
tags = [] tags = []
for book in self.mention_books.all(): for book in self.mention_books.all():
tags.append(activitypub.Link( tags.append(activitypub.Link(
@ -66,6 +68,11 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
name=book.title, name=book.title,
type='Book' type='Book'
)) ))
for user in self.mention_users.all():
tags.append(activitypub.Mention(
href=user.remote_id,
name=user.username,
))
return tags return tags
shared_mappings = [ shared_mappings = [
@ -117,7 +124,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
**kwargs **kwargs
) )
def to_activity(self, **kwargs): def to_activity(self, pure=False):
''' return tombstone if the status is deleted ''' ''' return tombstone if the status is deleted '''
if self.deleted: if self.deleted:
return activitypub.Tombstone( return activitypub.Tombstone(
@ -126,7 +133,12 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
deleted=self.deleted_date.isoformat(), deleted=self.deleted_date.isoformat(),
published=self.deleted_date.isoformat() published=self.deleted_date.isoformat()
).serialize() ).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): class GeneratedNote(Status):
@ -227,6 +239,11 @@ class Favorite(ActivitypubMixin, BookWyrmModel):
activity_serializer = activitypub.Like 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: class Meta:
''' can't fav things twice ''' ''' can't fav things twice '''
@ -267,10 +284,15 @@ class ReadThrough(BookWyrmModel):
blank=True, blank=True,
null=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 = models.TextChoices(
'NotificationType', 'NotificationType',
'FAVORITE REPLY TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT') 'FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT')
class Notification(BookWyrmModel): class Notification(BookWyrmModel):
''' you've been tagged, liked, followed, etc ''' ''' 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) remote_id = models.CharField(max_length=255, null=True, unique=True)
created_date = models.DateTimeField(auto_now_add=True) created_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True) updated_date = models.DateTimeField(auto_now=True)
last_active_date = models.DateTimeField(auto_now=True)
manually_approves_followers = models.BooleanField(default=False) manually_approves_followers = models.BooleanField(default=False)
# ---- activitypub serialization settings for this model ----- # # ---- activitypub serialization settings for this model ----- #
@ -167,28 +168,30 @@ class User(OrderedCollectionPageMixin, AbstractUser):
return activity_object return activity_object
@receiver(models.signals.pre_save, sender=User) def save(self, *args, **kwargs):
def execute_before_save(sender, instance, *args, **kwargs):
''' populate fields for new local users ''' ''' populate fields for new local users '''
# this user already exists, no need to poplate fields # this user already exists, no need to populate fields
if instance.id: if self.id:
return return
if not instance.local:
# we need to generate a username that uses the domain (webfinger format) if not self.local:
actor_parts = urlparse(instance.remote_id) # generate a username that uses the domain (webfinger format)
instance.username = '%s@%s' % (instance.username, actor_parts.netloc) actor_parts = urlparse(self.remote_id)
self.username = '%s@%s' % (self.username, actor_parts.netloc)
return return
# populate fields for local users # populate fields for local users
instance.remote_id = 'https://%s/user/%s' % (DOMAIN, instance.username) self.remote_id = 'https://%s/user/%s' % (DOMAIN, self.username)
instance.localname = instance.username self.localname = self.username
instance.username = '%s@%s' % (instance.username, DOMAIN) self.username = '%s@%s' % (self.username, DOMAIN)
instance.actor = instance.remote_id self.actor = self.remote_id
instance.inbox = '%s/inbox' % instance.remote_id self.inbox = '%s/inbox' % self.remote_id
instance.shared_inbox = 'https://%s/inbox' % DOMAIN self.shared_inbox = 'https://%s/inbox' % DOMAIN
instance.outbox = '%s/outbox' % instance.remote_id self.outbox = '%s/outbox' % self.remote_id
if not instance.private_key: if not self.private_key:
instance.private_key, instance.public_key = create_key_pair() self.private_key, self.public_key = create_key_pair()
super().save(*args, **kwargs)
@receiver(models.signals.post_save, sender=User) @receiver(models.signals.post_save, sender=User)

View file

@ -1,5 +1,6 @@
''' handles all the activity coming out of the server ''' ''' handles all the activity coming out of the server '''
from datetime import datetime from datetime import datetime
import re
from django.db import IntegrityError, transaction from django.db import IntegrityError, transaction
from django.http import HttpResponseNotFound, JsonResponse 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 create_generated_note
from bookwyrm.status import delete_status from bookwyrm.status import delete_status
from bookwyrm.remote_user import get_or_create_remote_user from bookwyrm.remote_user import get_or_create_remote_user
from bookwyrm.settings import DOMAIN
from bookwyrm.utils import regex
@csrf_exempt @csrf_exempt
@ -34,13 +37,17 @@ def outbox(request, username):
def handle_remote_webfinger(query): def handle_remote_webfinger(query):
''' webfingerin' other servers ''' ''' webfingerin' other servers, username query should be user@domain '''
user = None user = None
domain = query.split('@')[1] try:
domain = query.split('@')[2]
except IndexError:
return None
try: try:
user = models.User.objects.get(username=query) user = models.User.objects.get(username=query)
except models.User.DoesNotExist: except models.User.DoesNotExist:
url = 'https://%s/.well-known/webfinger?resource=acct:%s' % \ url = 'https://%s/.well-known/webfinger?resource=acct:@%s' % \
(domain, query) (domain, query)
try: try:
response = requests.get(url) response = requests.get(url)
@ -55,7 +62,7 @@ def handle_remote_webfinger(query):
user = get_or_create_remote_user(link['href']) user = get_or_create_remote_user(link['href'])
except KeyError: except KeyError:
return None return None
return [user] return user
def handle_follow(user, to_follow): def handle_follow(user, to_follow):
@ -211,7 +218,36 @@ def handle_status(user, form):
''' generic handler for statuses ''' ''' generic handler for statuses '''
status = form.save() 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: if status.reply_parent and status.reply_parent.user.local:
create_notification( create_notification(
status.reply_parent.user, status.reply_parent.user,

View file

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

View file

@ -22,6 +22,10 @@
favorited your favorited your
<a href="{{ notification.related_status.remote_id}}">status</a> <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' %} {% elif notification.notification_type == 'REPLY' %}
<a href="{{ notification.related_status.remote_id}}">replied</a> <a href="{{ notification.related_status.remote_id}}">replied</a>
to your 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) 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) @register.simple_tag(takes_context=True)
def shelve_button_identifier(context, book): def shelve_button_identifier(context, book):
''' check what shelf a user has a book on, if any ''' ''' check what shelf a user has a book on, if any '''

View file

@ -17,7 +17,7 @@ status_types = [
'comment', 'comment',
'quotation', 'quotation',
'boost', 'boost',
'generatedstatus' 'generatednote'
] ]
status_path = r'%s/(%s)/(?P<status_id>\d+)' % \ status_path = r'%s/(%s)/(?P<status_id>\d+)' % \
(local_user_path, '|'.join(status_types)) (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 import dateutil.parser
from dateutil.parser import ParserError from dateutil.parser import ParserError
from django.contrib.auth import authenticate, login, logout from django.contrib.auth import authenticate, login, logout
from django.contrib.auth.decorators import login_required, permission_required 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.core.files.base import ContentFile
from django.http import HttpResponseBadRequest, HttpResponseNotFound from django.http import HttpResponseBadRequest, HttpResponseNotFound
from django.shortcuts import redirect from django.shortcuts import redirect
from django.template.response import TemplateResponse 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 books_manager
from bookwyrm import forms, models, outgoing from bookwyrm import forms, models, outgoing
@ -32,7 +34,9 @@ def user_login(request):
password = login_form.data['password'] password = login_form.data['password']
user = authenticate(request, username=username, password=password) user = authenticate(request, username=username, password=password)
if user is not None: if user is not None:
# successful login
login(request, user) login(request, user)
user.last_active_date = timezone.now()
return redirect(request.GET.get('next', '/')) return redirect(request.GET.get('next', '/'))
login_form.non_field_errors = 'Username or password are incorrect' 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 forms, models, books_manager
from bookwyrm import goodreads_import from bookwyrm import goodreads_import
from bookwyrm.tasks import app from bookwyrm.tasks import app
from bookwyrm.utils import regex
def get_user_from_username(username): def get_user_from_username(username):
@ -168,7 +169,7 @@ def search(request):
return JsonResponse([r.__dict__ for r in book_results], safe=False) return JsonResponse([r.__dict__ for r in book_results], safe=False)
# use webfinger looks like a mastodon style account@domain.com username # 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) outgoing.handle_remote_webfinger(query)
# do a local user search # do a local user search

View file

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