diff --git a/bookwyrm/activitypub/__init__.py b/bookwyrm/activitypub/__init__.py index c10d1ca1c..105e889b6 100644 --- a/bookwyrm/activitypub/__init__.py +++ b/bookwyrm/activitypub/__init__.py @@ -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 diff --git a/bookwyrm/activitypub/base_activity.py b/bookwyrm/activitypub/base_activity.py index 79987b50a..54cc4679d 100644 --- a/bookwyrm/activitypub/base_activity.py +++ b/bookwyrm/activitypub/base_activity.py @@ -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 ''' diff --git a/bookwyrm/activitypub/note.py b/bookwyrm/activitypub/note.py index d187acc6e..357e164f2 100644 --- a/bookwyrm/activitypub/note.py +++ b/bookwyrm/activitypub/note.py @@ -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' diff --git a/bookwyrm/incoming.py b/bookwyrm/incoming.py index 56bea4567..30d741e8a 100644 --- a/bookwyrm/incoming.py +++ b/bookwyrm/incoming.py @@ -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) diff --git a/bookwyrm/migrations/0063_auto_20201101_1758.py b/bookwyrm/migrations/0063_auto_20201101_1758.py new file mode 100644 index 000000000..758548f75 --- /dev/null +++ b/bookwyrm/migrations/0063_auto_20201101_1758.py @@ -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'), + ), + ] diff --git a/bookwyrm/migrations/0063_user_last_active_date.py b/bookwyrm/migrations/0063_user_last_active_date.py new file mode 100644 index 000000000..0cecc37d2 --- /dev/null +++ b/bookwyrm/migrations/0063_user_last_active_date.py @@ -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), + ), + ] diff --git a/bookwyrm/models/status.py b/bookwyrm/models/status.py index 36dbb06d8..61a22ce7e 100644 --- a/bookwyrm/models/status.py +++ b/bookwyrm/models/status.py @@ -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 ''' diff --git a/bookwyrm/models/user.py b/bookwyrm/models/user.py index 38442ed75..bebd55406 100644 --- a/bookwyrm/models/user.py +++ b/bookwyrm/models/user.py @@ -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) diff --git a/bookwyrm/outgoing.py b/bookwyrm/outgoing.py index d236819e2..95b89d9f9 100644 --- a/bookwyrm/outgoing.py +++ b/bookwyrm/outgoing.py @@ -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, diff --git a/bookwyrm/static/css/format.css b/bookwyrm/static/css/format.css index 3f16d94d4..bd13143b3 100644 --- a/bookwyrm/static/css/format.css +++ b/bookwyrm/static/css/format.css @@ -113,7 +113,7 @@ input.toggle-control:checked ~ .toggle-content { left: 0; } .quote blockquote:after { - content: "\e903"; + content: "\e904"; right: 0; } diff --git a/bookwyrm/templates/notifications.html b/bookwyrm/templates/notifications.html index e59810b9a..afd4d8aea 100644 --- a/bookwyrm/templates/notifications.html +++ b/bookwyrm/templates/notifications.html @@ -22,6 +22,10 @@ favorited your status + {% elif notification.notification_type == 'MENTION' %} + mentioned you in a + status + {% elif notification.notification_type == 'REPLY' %} replied to your diff --git a/bookwyrm/templates/snippets/book_description.html b/bookwyrm/templates/snippets/book_description.html index 12c9ccfb0..839a80986 100644 --- a/bookwyrm/templates/snippets/book_description.html +++ b/bookwyrm/templates/snippets/book_description.html @@ -1,2 +1,20 @@ -
{% if book.description %}{{ book.description }}{% elif book.parent_work.description %}{{ book.parent_work.description }}{% endif %}
- +{% load fr_display %} +{% with book|book_description as full %} + {% with full|text_overflow as trimmed %} + {% if trimmed != full %} +
+ + +
+
+ + +
+ {% else %} +
{{ full }} +
+ {% endif %} + {% endwith %} +{% endwith %} diff --git a/bookwyrm/templatetags/fr_display.py b/bookwyrm/templatetags/fr_display.py index cb4ee4198..9e6e35cd5 100644 --- a/bookwyrm/templatetags/fr_display.py +++ b/bookwyrm/templatetags/fr_display.py @@ -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 ''' diff --git a/bookwyrm/urls.py b/bookwyrm/urls.py index 19fdf2e46..78c99ae4a 100644 --- a/bookwyrm/urls.py +++ b/bookwyrm/urls.py @@ -17,7 +17,7 @@ status_types = [ 'comment', 'quotation', 'boost', - 'generatedstatus' + 'generatednote' ] status_path = r'%s/(%s)/(?P\d+)' % \ (local_user_path, '|'.join(status_types)) diff --git a/bookwyrm/utils/__init__.py b/bookwyrm/utils/__init__.py index e69de29bb..a90554c70 100644 --- a/bookwyrm/utils/__init__.py +++ b/bookwyrm/utils/__init__.py @@ -0,0 +1 @@ +from .regex import username diff --git a/bookwyrm/utils/regex.py b/bookwyrm/utils/regex.py new file mode 100644 index 000000000..36e211d98 --- /dev/null +++ b/bookwyrm/utils/regex.py @@ -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 diff --git a/bookwyrm/view_actions.py b/bookwyrm/view_actions.py index 4fca16857..e9a1171de 100644 --- a/bookwyrm/view_actions.py +++ b/bookwyrm/view_actions.py @@ -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' diff --git a/bookwyrm/views.py b/bookwyrm/views.py index 4721644c8..4666409da 100644 --- a/bookwyrm/views.py +++ b/bookwyrm/views.py @@ -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 diff --git a/bookwyrm/wellknown.py b/bookwyrm/wellknown.py index b59256fcb..c17e10fc0 100644 --- a/bookwyrm/wellknown.py +++ b/bookwyrm/wellknown.py @@ -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, },